From 6f3d7a4b31b2bb9df2fe3866c76880852e2fb28a Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Thu, 30 Apr 2026 15:30:46 +0100 Subject: [PATCH 01/58] bump deps --- Directory.Packages.props | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 81476e7..32eb69f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,21 +14,21 @@ - - - - + + + + - - - + + + @@ -46,11 +46,11 @@ - + - + From 510bac454a2e8e56fdc4f9605e4bb53bea5e9fff Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Sun, 3 May 2026 23:48:15 +0100 Subject: [PATCH 02/58] chore: cleanup files --- src/Typical.Core/Events/BackspacePressedEvent.cs | 3 --- src/Typical.Core/Events/GameEndedEvent.cs | 3 --- src/Typical.Core/Events/GameQuitEvent.cs | 3 --- src/Typical.Core/Events/KeyPressedEvent.cs | 5 ----- src/Typical/Views/TypingArea.cs | 6 +++--- 5 files changed, 3 insertions(+), 17 deletions(-) delete mode 100644 src/Typical.Core/Events/BackspacePressedEvent.cs delete mode 100644 src/Typical.Core/Events/GameEndedEvent.cs delete mode 100644 src/Typical.Core/Events/GameQuitEvent.cs delete mode 100644 src/Typical.Core/Events/KeyPressedEvent.cs diff --git a/src/Typical.Core/Events/BackspacePressedEvent.cs b/src/Typical.Core/Events/BackspacePressedEvent.cs deleted file mode 100644 index 1a4afc1..0000000 --- a/src/Typical.Core/Events/BackspacePressedEvent.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Typical.Core.Events; - -internal record BackspacePressedEvent; diff --git a/src/Typical.Core/Events/GameEndedEvent.cs b/src/Typical.Core/Events/GameEndedEvent.cs deleted file mode 100644 index f94565c..0000000 --- a/src/Typical.Core/Events/GameEndedEvent.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Typical.Core.Events; - -public record GameEndedEvent; diff --git a/src/Typical.Core/Events/GameQuitEvent.cs b/src/Typical.Core/Events/GameQuitEvent.cs deleted file mode 100644 index 120b7d7..0000000 --- a/src/Typical.Core/Events/GameQuitEvent.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Typical.Core.Events; - -public record GameQuitEvent; diff --git a/src/Typical.Core/Events/KeyPressedEvent.cs b/src/Typical.Core/Events/KeyPressedEvent.cs deleted file mode 100644 index 60bd276..0000000 --- a/src/Typical.Core/Events/KeyPressedEvent.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Typical.Core.Statistics; - -namespace Typical.Core.Events; - -internal record KeyPressedEvent(char Character, KeystrokeType Type, int Position); diff --git a/src/Typical/Views/TypingArea.cs b/src/Typical/Views/TypingArea.cs index 062ded4..8bea7c0 100644 --- a/src/Typical/Views/TypingArea.cs +++ b/src/Typical/Views/TypingArea.cs @@ -1,9 +1,12 @@ using System.Text; + using Terminal.Gui.Configuration; using Terminal.Gui.Text; using Terminal.Gui.ViewBase; + using Typical.Core.Statistics; using Typical.Core.ViewModels; + using Attribute = Terminal.Gui.Drawing.Attribute; namespace Typical.Views; @@ -26,9 +29,6 @@ public TypingArea(TypingViewModel viewModel) _correctAttr = normalScheme!.HotNormal; _incorrectAttr = errorScheme!.Active; _untypedAttr = normalScheme!.Normal; - // _correctAttr = new Attribute(Terminal.Gui.Drawing.ColorName16.Blue); - // _incorrectAttr = new Attribute(Terminal.Gui.Drawing.ColorName16.Red); - // _untypedAttr = new Attribute(Terminal.Gui.Drawing.ColorName16.Gray); } public void Refresh() From 8e0b3be1bcb8f9d66cb4417a6205028767dd2abe Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Tue, 5 May 2026 13:30:51 +0100 Subject: [PATCH 03/58] chore: formatting --- .vscode/settings.json | 5 +++++ src/Typical.Core/ViewModels/TypingViewModel.cs | 3 --- src/Typical.DataAccess/Typical.DataAccess.csproj | 1 - src/Typical.Tests/StatsViewModelTests.cs | 1 - src/Typical/Views/TypingArea.cs | 3 --- 5 files changed, 5 insertions(+), 8 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6e0a174 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[csharp]": { + "editor.defaultFormatter": "csharpier.csharpier-vscode" + } +} diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index 66b83a0..f9207cd 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -1,10 +1,7 @@ using System.Diagnostics.CodeAnalysis; - using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; - using Microsoft.Extensions.Logging; - using Typical.Core.Events; using Typical.Core.Interfaces; using Typical.Core.Statistics; diff --git a/src/Typical.DataAccess/Typical.DataAccess.csproj b/src/Typical.DataAccess/Typical.DataAccess.csproj index a6a791e..077b1d4 100644 --- a/src/Typical.DataAccess/Typical.DataAccess.csproj +++ b/src/Typical.DataAccess/Typical.DataAccess.csproj @@ -2,7 +2,6 @@ true - diff --git a/src/Typical.Tests/StatsViewModelTests.cs b/src/Typical.Tests/StatsViewModelTests.cs index a281a28..0ab8151 100644 --- a/src/Typical.Tests/StatsViewModelTests.cs +++ b/src/Typical.Tests/StatsViewModelTests.cs @@ -1,5 +1,4 @@ using CommunityToolkit.Mvvm.Messaging; - using Typical.Core.Events; using Typical.Core.Statistics; using Typical.Core.ViewModels; diff --git a/src/Typical/Views/TypingArea.cs b/src/Typical/Views/TypingArea.cs index 8bea7c0..b4a382d 100644 --- a/src/Typical/Views/TypingArea.cs +++ b/src/Typical/Views/TypingArea.cs @@ -1,12 +1,9 @@ using System.Text; - using Terminal.Gui.Configuration; using Terminal.Gui.Text; using Terminal.Gui.ViewBase; - using Typical.Core.Statistics; using Typical.Core.ViewModels; - using Attribute = Terminal.Gui.Drawing.Attribute; namespace Typical.Views; From 0e5664a671d99ecf8709a497aa51b1b5741a7fa4 Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Thu, 7 May 2026 17:25:48 +0100 Subject: [PATCH 04/58] feat: support for non-english languages - use Terminal.Gui key.AsGrapheme instead of utf-16 chars - update engine to use string.Normalize() and StringInfo.TextElement - add tests resolves: #43 --- src/Typical.Core/GameEngine.cs | 74 +++++++++------- src/Typical.Core/Statistics/GameStats.cs | 6 +- src/Typical.Core/Statistics/KeystrokeLog.cs | 2 +- .../ViewModels/TypingViewModel.cs | 22 +---- src/Typical.Tests/GameEngineTests.cs | 87 ++++++++++++++++--- src/Typical.Tests/TestDataSources.cs | 58 +++++++++++++ src/Typical.Tests/Typical.Tests.csproj | 2 + src/Typical/Views/TypingArea.cs | 40 +++++++-- src/Typical/Views/TypingView.cs | 5 +- 9 files changed, 218 insertions(+), 78 deletions(-) create mode 100644 src/Typical.Tests/TestDataSources.cs diff --git a/src/Typical.Core/GameEngine.cs b/src/Typical.Core/GameEngine.cs index bae4881..9ef03b0 100644 --- a/src/Typical.Core/GameEngine.cs +++ b/src/Typical.Core/GameEngine.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using System.Text; using Microsoft.Extensions.Logging; using Typical.Core.Events; @@ -10,14 +11,14 @@ namespace Typical.Core; public class GameEngine { - private readonly StringBuilder _userInput = new(); + private string[] _targetGraphemes = []; + private readonly List _userInputGraphemes = []; private readonly GameOptions _gameOptions; // TODO: Add HeatmapCollector private readonly ILogger _logger; - private KeystrokeType[] _charStates = []; - public IReadOnlyList CharacterStates => _charStates; + private readonly StringBuilder _userInputBuffer = new StringBuilder(); public GameEngine(GameOptions gameOptions, ILogger logger) { @@ -26,71 +27,68 @@ public GameEngine(GameOptions gameOptions, ILogger logger) _logger = logger; } + public IReadOnlyList CharacterStates => _charStates; public GameStats Stats { get; private set; } public string TargetText { get; private set; } = string.Empty; - public string UserInput => _userInput.ToString(); + public string UserInput => _userInputBuffer.ToString(); public bool IsOver { get; private set; } public bool IsRunning => !IsOver && Stats.IsRunning; public int TargetFrameDelayMilliseconds => 1000 / _gameOptions.TargetFrameRate; public GameSnapshot CreateSnapshot() => Stats.CreateSnapshot(TargetText, UserInput, IsOver); - public bool ProcessKeyPress(char c, bool isBackspace) + public bool ProcessKeyPress(string input, bool isBackspace) { - if (!IsRunning && !IsOver && TargetText.Length > 0 && !isBackspace) + if (!IsRunning && !IsOver && !isBackspace) { Stats.Start(); CoreLogs.GameStarting(_logger); } - int currentPos = _userInput.Length; + int currentPos = _userInputGraphemes.Count; if (isBackspace) { if (currentPos > 0) { - _userInput.Remove(currentPos - 1, 1); + string lastGrapheme = _userInputGraphemes[^1]; + _userInputGraphemes.RemoveAt(currentPos - 1); + _userInputBuffer.Remove( + _userInputBuffer.Length - lastGrapheme.Length, + lastGrapheme.Length + ); _charStates[currentPos - 1] = KeystrokeType.Untyped; Stats.RecordBackspace(); } return true; } - if (currentPos >= TargetText.Length) + if (currentPos >= _targetGraphemes.Length) return false; - var type = DetermineKeystrokeType(c); - Stats.RecordKey(c, type); + // var type = DetermineKeystrokeType(c); + string normalizedInput = input.Normalize(NormalizationForm.FormC); + bool isCorrect = normalizedInput == _targetGraphemes[currentPos]; + Stats.RecordKey(normalizedInput, _charStates[currentPos]); - bool isCorrect = type == KeystrokeType.Correct; if (!_gameOptions.ForbidIncorrectEntries || isCorrect) { - _userInput.Append(c); - _charStates[currentPos] = type; - } - else - { - return false; + _userInputGraphemes.Add(normalizedInput); + _userInputBuffer.Append(normalizedInput); + _charStates[currentPos] = isCorrect ? KeystrokeType.Correct : KeystrokeType.Incorrect; } CheckEndCondition(); return true; } - private KeystrokeType DetermineKeystrokeType(char inputChar) - { - int currentPos = _userInput.Length; - return currentPos >= TargetText.Length ? KeystrokeType.Extra - : inputChar == TargetText[currentPos] ? KeystrokeType.Correct - : KeystrokeType.Incorrect; - } - private void CheckEndCondition() { - if (_userInput.Length == TargetText.Length) + if (_userInputGraphemes.Count == _targetGraphemes.Length) { - if (_userInput.ToString().Equals(TargetText) || !_gameOptions.Require100Accuracy) + bool hasErrors = _charStates.Any(s => s == KeystrokeType.Incorrect); + if (!hasErrors || !_gameOptions.Require100Accuracy) { IsOver = true; Stats.Stop(); @@ -100,10 +98,22 @@ private void CheckEndCondition() public void LoadText(TextSample sample) { - var text = sample.Text; - TargetText = text; - _userInput.Clear(); - _charStates = new KeystrokeType[text.Length]; + TargetText = sample.Text.Normalize(NormalizationForm.FormC); + + List list = []; + var enumerator = StringInfo.GetTextElementEnumerator(TargetText); + enumerator.Reset(); + while (enumerator.MoveNext()) + { + list.Add(enumerator.GetTextElement()); + } + + _targetGraphemes = list.ToArray(); + + _userInputGraphemes.Clear(); + _userInputBuffer.Clear(); + + _charStates = new KeystrokeType[_targetGraphemes.Length]; Array.Fill(_charStates, KeystrokeType.Untyped); IsOver = false; diff --git a/src/Typical.Core/Statistics/GameStats.cs b/src/Typical.Core/Statistics/GameStats.cs index e481c71..c5659e3 100644 --- a/src/Typical.Core/Statistics/GameStats.cs +++ b/src/Typical.Core/Statistics/GameStats.cs @@ -39,13 +39,13 @@ private void UpdateCounts(KeystrokeType type, int change) } } - internal void RecordKey(char c, KeystrokeType type) + internal void RecordKey(string grapheme, KeystrokeType type) { if (!IsRunning) Start(); UpdateCounts(type, 1); - _logs.AddAndDebug(new KeystrokeLog(c, type, _timeProvider.GetTimestamp())); + _logs.AddAndDebug(new KeystrokeLog(grapheme, type, _timeProvider.GetTimestamp())); } internal void RecordBackspace() @@ -61,7 +61,7 @@ internal void RecordBackspace() } UpdateCounts(KeystrokeType.Correction, 1); _logs.AddAndDebug( - new KeystrokeLog('\b', KeystrokeType.Correction, _timeProvider.GetTimestamp()) + new KeystrokeLog("\b", KeystrokeType.Correction, _timeProvider.GetTimestamp()) ); } diff --git a/src/Typical.Core/Statistics/KeystrokeLog.cs b/src/Typical.Core/Statistics/KeystrokeLog.cs index 0c94f2c..0649153 100644 --- a/src/Typical.Core/Statistics/KeystrokeLog.cs +++ b/src/Typical.Core/Statistics/KeystrokeLog.cs @@ -1,3 +1,3 @@ namespace Typical.Core.Statistics; -public record struct KeystrokeLog(char Character, KeystrokeType Type, long Timestamp); +public record struct KeystrokeLog(string Grapheme, KeystrokeType Type, long Timestamp); diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index f9207cd..5a05bb9 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -53,7 +53,7 @@ ILogger logger /// Processes input received from the View. /// Maps Key events to Core Game Logic. /// - public async void ProcessInput(char c, bool isBackspace) + public async void ProcessInput(string c, bool isBackspace) { if (_engine.IsOver) { @@ -63,18 +63,7 @@ public async void ProcessInput(char c, bool isBackspace) bool accepted = _engine.ProcessKeyPress(c, isBackspace); - var states = _engine.CharacterStates.ToArray(); - - if (!accepted && !isBackspace && c != '\0') - { - int pos = _engine.UserInput.Length; - if (pos < states.Length) - { - states[pos] = KeystrokeType.Incorrect; - } - } - - DisplayStates = states; + DisplayStates = _engine.CharacterStates.ToArray(); UpdateState(); } @@ -91,13 +80,6 @@ private void UpdateState() WeakReferenceMessenger.Default.Send(new GameStateUpdatedMessage(snapshot)); } - public KeystrokeType GetStatus(int index) - { - return index < 0 || index >= _engine.CharacterStates.Count - ? KeystrokeType.Untyped - : _engine.CharacterStates[index]; - } - public void OnNavigatedTo() { _logger.LogInformation($"Navigated to {nameof(TypingViewModel)}"); diff --git a/src/Typical.Tests/GameEngineTests.cs b/src/Typical.Tests/GameEngineTests.cs index 71c6e19..c4eecd4 100644 --- a/src/Typical.Tests/GameEngineTests.cs +++ b/src/Typical.Tests/GameEngineTests.cs @@ -1,5 +1,9 @@ +using System.Globalization; +using System.Text; +using Bogus; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using TUnit.Core.Logging; using Typical.Core; using Typical.Core.Events; using Typical.Core.Statistics; @@ -12,11 +16,16 @@ public class TypicalGameTests private readonly MockTextProvider _mockTextProvider; private readonly GameOptions _defaultOptions; private readonly GameOptions _strictOptions; - private readonly ILogger _logger; + private readonly Microsoft.Extensions.Logging.ILogger _logger; private readonly GameStats _stats; + const int BOGUS_SEED = 999_999_001; + private readonly Random SEED = new Random(BOGUS_SEED); + private readonly TUnit.Core.Logging.DefaultLogger logger; public TypicalGameTests() { + logger = TestContext.Current!.GetDefaultLogger(); + Bogus.Randomizer.Seed = SEED; // This runs before each test, ensuring a clean state. _mockTextProvider = new MockTextProvider(); _defaultOptions = new GameOptions(); @@ -54,8 +63,8 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() game.LoadText(new TextSample() { Text = firstText, Source = "test" }); // 3. Simulate playing the game - game.ProcessKeyPress('s', false); // Correct first char - game.ProcessKeyPress('o', false); + game.ProcessKeyPress("s", false); // Correct first char + game.ProcessKeyPress("o", false); // Check that we actually have progress await Assert.That(game.UserInput).IsEqualTo("so"); @@ -86,12 +95,12 @@ public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() game.LoadText(new TextSample() { Text = "abc", Source = "test" }); - game.ProcessKeyPress('a', false); - game.ProcessKeyPress('b', false); + game.ProcessKeyPress("a", false); + game.ProcessKeyPress("b", false); await Assert.That(game.UserInput).IsEqualTo("ab"); // Act - Pass '\0' or any char with isBackspace = true - game.ProcessKeyPress('\0', true); + game.ProcessKeyPress("\0", true); // Assert await Assert.That(game.UserInput).IsEqualTo("a"); @@ -107,7 +116,7 @@ public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() await Assert.That(game.UserInput).IsEmpty(); // Act - game.ProcessKeyPress('\0', true); + game.ProcessKeyPress("\0", true); // Assert await Assert.That(game.UserInput).IsEmpty(); @@ -121,8 +130,8 @@ public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() game.LoadText(new TextSample() { Text = "hi", Source = "test" }); // Act - game.ProcessKeyPress('h', false); - game.ProcessKeyPress('i', false); + game.ProcessKeyPress("h", false); + game.ProcessKeyPress("i", false); // Assert await Assert.That(game.UserInput).IsEqualTo("hi"); @@ -140,7 +149,7 @@ public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() game.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act - bool result = game.ProcessKeyPress('a', false); + bool result = game.ProcessKeyPress("a", false); // Assert await Assert.That(result).IsTrue(); @@ -156,10 +165,10 @@ public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendChara game.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act - bool result = game.ProcessKeyPress('x', false); + bool result = game.ProcessKeyPress("x", false); // Assert - await Assert.That(result).IsFalse(); // Engine rejected the key + await Assert.That(result).IsTrue(); // Engine rejected the key await Assert.That(game.UserInput).IsEmpty(); await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Untyped); } @@ -172,11 +181,63 @@ public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter( game.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act - bool result = game.ProcessKeyPress('x', false); + bool result = game.ProcessKeyPress("x", false); // Assert await Assert.That(result).IsTrue(); // Engine accepted the mistake await Assert.That(game.UserInput).IsEqualTo("x"); await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Incorrect); } + + [Test] + public async Task ProcessKeyPress_WithRandomText_MatchesState() + { + var lorem = new Bogus.DataSets.Lorem("ru") { Random = new Bogus.Randomizer(BOGUS_SEED) }; + + var text = lorem.Sentence(); + + var sut = new GameEngine(_defaultOptions, _logger); + sut.LoadText(new TextSample() { Text = text, Source = "Bogus" }); + + var enumerator = StringInfo.GetTextElementEnumerator(text); + + while (enumerator.MoveNext()) + { + string nextGrapheme = enumerator.GetTextElement(); + bool result = sut.ProcessKeyPress(nextGrapheme, false); + await Assert.That(result).IsTrue(); + } + + await Assert.That(sut.IsOver).IsTrue(); + } + + [Test] + [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] + public async Task Engine_ShouldHandleWordsInInternationalLocales(string locale) + { + var faker = new Faker(locale); + var internationalText = faker.Random.Words(10); + var _engine = new GameEngine(_defaultOptions, _logger); + _engine.LoadText(new TextSample() { Text = internationalText, Source = locale }); + + var visualCount = new StringInfo( + internationalText.Normalize(NormalizationForm.FormC) + ).LengthInTextElements; + await Assert.That(visualCount).IsEqualTo(_engine.CharacterStates.Count); + } + + [Test] + [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] + public async Task Engine_ShouldHandleSentencesInInternationalLocales(string locale) + { + var faker = new Faker(locale); + var _engine = new GameEngine(_defaultOptions, _logger); + var textSample = new TextSample() { Text = faker.Lorem.Sentence(), Source = locale }; + _engine.LoadText(textSample); + await logger.LogDebugAsync(textSample.ToString()); + var visualCount = new StringInfo( + textSample.Text.Normalize(NormalizationForm.FormC) + ).LengthInTextElements; + await Assert.That(visualCount).IsEqualTo(_engine.CharacterStates.Count); + } } diff --git a/src/Typical.Tests/TestDataSources.cs b/src/Typical.Tests/TestDataSources.cs new file mode 100644 index 0000000..602637e --- /dev/null +++ b/src/Typical.Tests/TestDataSources.cs @@ -0,0 +1,58 @@ +namespace Typical.Tests; + +public static class TestDataSources +{ + public static IEnumerable> AdditionTestData() + { + yield return () => "af_ZA"; + yield return () => "fr_CH"; + yield return () => "ar"; + yield return () => "ge"; + yield return () => "az"; + yield return () => "hr"; + yield return () => "cz"; + yield return () => "id_ID"; + yield return () => "de"; + yield return () => "it"; + yield return () => "de_AT"; + yield return () => "ja"; + yield return () => "de_CH"; + yield return () => "ko"; + yield return () => "el"; + yield return () => "lv"; + yield return () => "en"; + yield return () => "nb_NO"; + yield return () => "en_AU"; + yield return () => "ne"; + yield return () => "en_AU_ocker"; + yield return () => "nl"; + yield return () => "en_BORK"; + yield return () => "nl_BE"; + yield return () => "en_CA"; + yield return () => "pl"; + yield return () => "en_GB"; + yield return () => "pt_BR"; + yield return () => "en_IE"; + yield return () => "pt_PT"; + yield return () => "en_IND"; + yield return () => "ro"; + yield return () => "en_NG"; + yield return () => "ru"; + yield return () => "en_US"; + yield return () => "sk"; + yield return () => "en_ZA"; + yield return () => "sv"; + yield return () => "es"; + yield return () => "tr"; + yield return () => "es_MX"; + yield return () => "uk"; + yield return () => "fa"; + yield return () => "vi"; + yield return () => "fi"; + yield return () => "zh_CN"; + yield return () => "fr"; + yield return () => "zh_TW"; + yield return () => "fr_CA"; + yield return () => "zu_ZA"; + } +} diff --git a/src/Typical.Tests/Typical.Tests.csproj b/src/Typical.Tests/Typical.Tests.csproj index 8739720..0bf1671 100644 --- a/src/Typical.Tests/Typical.Tests.csproj +++ b/src/Typical.Tests/Typical.Tests.csproj @@ -1,8 +1,10 @@  Exe + true + diff --git a/src/Typical/Views/TypingArea.cs b/src/Typical/Views/TypingArea.cs index b4a382d..73d1d93 100644 --- a/src/Typical/Views/TypingArea.cs +++ b/src/Typical/Views/TypingArea.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using Terminal.Gui.Configuration; using Terminal.Gui.Text; @@ -51,29 +52,52 @@ protected override bool OnDrawingContent(DrawContext? context) if (_cachedLines.Count == 0 || Viewport.Width == 0) return true; - int yOffset = Math.Max(0, (Viewport.Height - _cachedLines.Count) / 2); int globalIdx = 0; - for (int y = 0; y < _cachedLines.Count; y++) + int yPos = Math.Max(0, (Viewport.Height - _cachedLines.Count) / 2); + foreach (var line in _cachedLines) { - string line = _cachedLines[y]; - int xOffset = Math.Max(0, (Viewport.Width - line.Length) / 2); + int xPos = CalculateXOffset(line); - for (int x = 0; x < line.Length; x++) + var enumerator = StringInfo.GetTextElementEnumerator(line); + while (enumerator.MoveNext()) { + string grapheme = enumerator.GetTextElement(); if (globalIdx >= _viewModel.DisplayStates.Length) break; var state = _viewModel.DisplayStates[globalIdx]; + Rune r = grapheme.EnumerateRunes().First(); + AddRune(xPos, yPos, r); - SetAttribute(GetAttributeForState(state)); - AddRune(x + xOffset, y + yOffset, (Rune)line[x]); - + xPos += r.GetColumns(); globalIdx++; } + yPos++; } + // int yOffset = Math.Max(0, (Viewport.Height - _cachedLines.Count) / 2); + // for (int y = 0; y < _cachedLines.Count; y++) + // { + // string line = _cachedLines[y]; + // int xOffset = Math.Max(0, (Viewport.Width - line.Length) / 2); + + // for (int x = 0; x < line.Length; x++) + // { + // if (globalIdx >= _viewModel.DisplayStates.Length) + // break; + + // var state = _viewModel.DisplayStates[globalIdx]; + + // SetAttribute(GetAttributeForState(state)); + // AddRune(x + xOffset, y + yOffset, (Rune)line[x]); + + // globalIdx++; + // } + // } return true; } + private int CalculateXOffset(string line) => Math.Max(0, (Viewport.Width - line.Length) / 2); + private Attribute GetAttributeForState(KeystrokeType state) => state switch { diff --git a/src/Typical/Views/TypingView.cs b/src/Typical/Views/TypingView.cs index c7ffaa8..7de9f6f 100644 --- a/src/Typical/Views/TypingView.cs +++ b/src/Typical/Views/TypingView.cs @@ -75,12 +75,15 @@ protected override bool OnKeyDown(Key key) bool isBackspace = key == Key.Backspace; Rune rune = key.AsRune; + if (rune == default) + return base.OnKeyDown(key); + if (rune != default || isBackspace) { char c = isBackspace ? '\0' : (char)rune.Value; try { - ViewModel.ProcessInput(c, isBackspace); + ViewModel.ProcessInput(key.AsGrapheme, isBackspace); } catch (Exception ex) { From 2ff86e1694b0e5b42e5e9500a62393cc0a82a88a Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Fri, 8 May 2026 14:01:29 +0100 Subject: [PATCH 05/58] fix: text rendering for AsGrapheme --- .editorconfig | 2 +- src/Typical.Core/GameEngine.cs | 2 +- .../Script_00200_SeedInitialQuotes.cs | 1 + src/Typical/Views/TypingArea.cs | 35 ++++++++----------- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/.editorconfig b/.editorconfig index 1db462e..eba74a3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -490,7 +490,7 @@ dotnet_diagnostic.RCS1042.severity = suggestion dotnet_diagnostic.RCS1043.severity = suggestion dotnet_diagnostic.RCS1045.severity = warning dotnet_diagnostic.RCS1050.severity = silent # Simplify object creation -dotnet_diagnostic.RCS1051.severity = suggestion +dotnet_diagnostic.RCS1051.severity = none dotnet_diagnostic.RCS1060.severity = suggestion dotnet_diagnostic.RCS1061.severity = suggestion dotnet_diagnostic.RCS1062.severity = suggestion diff --git a/src/Typical.Core/GameEngine.cs b/src/Typical.Core/GameEngine.cs index 9ef03b0..13b752f 100644 --- a/src/Typical.Core/GameEngine.cs +++ b/src/Typical.Core/GameEngine.cs @@ -70,7 +70,6 @@ public bool ProcessKeyPress(string input, bool isBackspace) // var type = DetermineKeystrokeType(c); string normalizedInput = input.Normalize(NormalizationForm.FormC); bool isCorrect = normalizedInput == _targetGraphemes[currentPos]; - Stats.RecordKey(normalizedInput, _charStates[currentPos]); if (!_gameOptions.ForbidIncorrectEntries || isCorrect) { @@ -79,6 +78,7 @@ public bool ProcessKeyPress(string input, bool isBackspace) _charStates[currentPos] = isCorrect ? KeystrokeType.Correct : KeystrokeType.Incorrect; } + Stats.RecordKey(normalizedInput, _charStates[currentPos]); CheckEndCondition(); return true; } diff --git a/src/Typical.DataAccess/Migrations/Script_00200_SeedInitialQuotes.cs b/src/Typical.DataAccess/Migrations/Script_00200_SeedInitialQuotes.cs index b402ac0..1dfbf72 100644 --- a/src/Typical.DataAccess/Migrations/Script_00200_SeedInitialQuotes.cs +++ b/src/Typical.DataAccess/Migrations/Script_00200_SeedInitialQuotes.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using DbUp; using DbUp.Engine; +#pragma warning disable RCS1060 // Declare each type in separate file namespace Typical.DataAccess.Sqlite; diff --git a/src/Typical/Views/TypingArea.cs b/src/Typical/Views/TypingArea.cs index 73d1d93..c502290 100644 --- a/src/Typical/Views/TypingArea.cs +++ b/src/Typical/Views/TypingArea.cs @@ -54,6 +54,7 @@ protected override bool OnDrawingContent(DrawContext? context) int globalIdx = 0; int yPos = Math.Max(0, (Viewport.Height - _cachedLines.Count) / 2); + foreach (var line in _cachedLines) { int xPos = CalculateXOffset(line); @@ -62,11 +63,16 @@ protected override bool OnDrawingContent(DrawContext? context) while (enumerator.MoveNext()) { string grapheme = enumerator.GetTextElement(); + if (globalIdx >= _viewModel.DisplayStates.Length) break; var state = _viewModel.DisplayStates[globalIdx]; + + SetAttribute(GetAttributeForState(state)); + Rune r = grapheme.EnumerateRunes().First(); + AddRune(xPos, yPos, r); xPos += r.GetColumns(); @@ -74,29 +80,18 @@ protected override bool OnDrawingContent(DrawContext? context) } yPos++; } - // int yOffset = Math.Max(0, (Viewport.Height - _cachedLines.Count) / 2); - // for (int y = 0; y < _cachedLines.Count; y++) - // { - // string line = _cachedLines[y]; - // int xOffset = Math.Max(0, (Viewport.Width - line.Length) / 2); - - // for (int x = 0; x < line.Length; x++) - // { - // if (globalIdx >= _viewModel.DisplayStates.Length) - // break; - - // var state = _viewModel.DisplayStates[globalIdx]; - - // SetAttribute(GetAttributeForState(state)); - // AddRune(x + xOffset, y + yOffset, (Rune)line[x]); - - // globalIdx++; - // } - // } return true; } - private int CalculateXOffset(string line) => Math.Max(0, (Viewport.Width - line.Length) / 2); + private int CalculateXOffset(string line) + { + int visualWidth = 0; + foreach (var rune in line.EnumerateRunes()) + { + visualWidth += rune.GetColumns(); + } + return Math.Max(0, (Viewport.Width - visualWidth) / 2); + } private Attribute GetAttributeForState(KeystrokeType state) => state switch From cce8c051a5d582c7514147541ef2b8a9e58303b7 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Tue, 12 May 2026 08:46:46 +0100 Subject: [PATCH 06/58] cleanup test file warnings --- src/Typical.Tests/GameEngineTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Typical.Tests/GameEngineTests.cs b/src/Typical.Tests/GameEngineTests.cs index c4eecd4..0cc55a9 100644 --- a/src/Typical.Tests/GameEngineTests.cs +++ b/src/Typical.Tests/GameEngineTests.cs @@ -18,14 +18,14 @@ public class TypicalGameTests private readonly GameOptions _strictOptions; private readonly Microsoft.Extensions.Logging.ILogger _logger; private readonly GameStats _stats; - const int BOGUS_SEED = 999_999_001; - private readonly Random SEED = new Random(BOGUS_SEED); - private readonly TUnit.Core.Logging.DefaultLogger logger; + private const int BOGUS_SEED = 999_999_001; + private readonly Random _seed = new Random(BOGUS_SEED); + private readonly DefaultLogger _testLogger; public TypicalGameTests() { - logger = TestContext.Current!.GetDefaultLogger(); - Bogus.Randomizer.Seed = SEED; + _testLogger = TestContext.Current!.GetDefaultLogger(); + Bogus.Randomizer.Seed = _seed; // This runs before each test, ensuring a clean state. _mockTextProvider = new MockTextProvider(); _defaultOptions = new GameOptions(); @@ -234,7 +234,7 @@ public async Task Engine_ShouldHandleSentencesInInternationalLocales(string loca var _engine = new GameEngine(_defaultOptions, _logger); var textSample = new TextSample() { Text = faker.Lorem.Sentence(), Source = locale }; _engine.LoadText(textSample); - await logger.LogDebugAsync(textSample.ToString()); + await _testLogger.LogDebugAsync(textSample.ToString()); var visualCount = new StringInfo( textSample.Text.Normalize(NormalizationForm.FormC) ).LengthInTextElements; From 140edc9d293a1c77b35c096c3e63cc7623c13077 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 13 May 2026 02:21:31 +0100 Subject: [PATCH 07/58] fix --- src/Typical.Core/Statistics/GameStats.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/Typical.Core/Statistics/GameStats.cs b/src/Typical.Core/Statistics/GameStats.cs index c5659e3..5d4dc20 100644 --- a/src/Typical.Core/Statistics/GameStats.cs +++ b/src/Typical.Core/Statistics/GameStats.cs @@ -50,15 +50,6 @@ internal void RecordKey(string grapheme, KeystrokeType type) internal void RecordBackspace() { - if (_logs.Count == 0) - return; - - int indexToRemove = _logs.FindLastIndex(log => log.Type != KeystrokeType.Correction); - - if (indexToRemove != -1) - { - _logs.RemoveAt(indexToRemove); - } UpdateCounts(KeystrokeType.Correction, 1); _logs.AddAndDebug( new KeystrokeLog("\b", KeystrokeType.Correction, _timeProvider.GetTimestamp()) From 0f77fd12679c017925a4c5546d1c3593e80a0d1c Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 13 May 2026 03:08:03 +0100 Subject: [PATCH 08/58] wip: recording snapshots --- src/Typical.Core/GameEngine.cs | 5 +- src/Typical.Core/Statistics/CharacterStats.cs | 2 +- .../Statistics/GameStatisticsSnapshot.cs | 53 +++++++++++---- src/Typical.Core/Statistics/GameStats.cs | 64 ++++++++++--------- src/Typical.Core/Statistics/README.md | 3 + src/Typical.Tests/StatsViewModelTests.cs | 10 ++- src/Typical/Views/StatsView.cs | 2 +- 7 files changed, 84 insertions(+), 55 deletions(-) create mode 100644 src/Typical.Core/Statistics/README.md diff --git a/src/Typical.Core/GameEngine.cs b/src/Typical.Core/GameEngine.cs index 13b752f..6853d50 100644 --- a/src/Typical.Core/GameEngine.cs +++ b/src/Typical.Core/GameEngine.cs @@ -28,7 +28,7 @@ public GameEngine(GameOptions gameOptions, ILogger logger) } public IReadOnlyList CharacterStates => _charStates; - public GameStats Stats { get; private set; } + internal GameStats Stats { get; private set; } public string TargetText { get; private set; } = string.Empty; public string UserInput => _userInputBuffer.ToString(); @@ -36,7 +36,7 @@ public GameEngine(GameOptions gameOptions, ILogger logger) public bool IsRunning => !IsOver && Stats.IsRunning; public int TargetFrameDelayMilliseconds => 1000 / _gameOptions.TargetFrameRate; - public GameSnapshot CreateSnapshot() => Stats.CreateSnapshot(TargetText, UserInput, IsOver); + public GameSnapshot CreateSnapshot() => Stats.CreateSnapshot(TargetText, UserInput); public bool ProcessKeyPress(string input, bool isBackspace) { @@ -67,7 +67,6 @@ public bool ProcessKeyPress(string input, bool isBackspace) if (currentPos >= _targetGraphemes.Length) return false; - // var type = DetermineKeystrokeType(c); string normalizedInput = input.Normalize(NormalizationForm.FormC); bool isCorrect = normalizedInput == _targetGraphemes[currentPos]; diff --git a/src/Typical.Core/Statistics/CharacterStats.cs b/src/Typical.Core/Statistics/CharacterStats.cs index f8675e5..7993575 100644 --- a/src/Typical.Core/Statistics/CharacterStats.cs +++ b/src/Typical.Core/Statistics/CharacterStats.cs @@ -1,3 +1,3 @@ namespace Typical.Core.Statistics; -public record CharacterStats(int Correct, int Incorrect, int Extra, int Corrections); +public record CharacterStats(int Correct, int Incorrect, int Corrections); diff --git a/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs b/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs index 339ceee..08a9276 100644 --- a/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs +++ b/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs @@ -3,27 +3,43 @@ namespace Typical.Core.Statistics; public readonly record struct GameSnapshot( - double WordsPerMinute, + WPM WPM, Accuracy Accuracy, CharacterStats Chars, TimeSpan ElapsedTime, - bool IsRunning, string TargetText, - string UserInput, - bool IsOver + string UserInput ) { - public static GameSnapshot Empty => - new( - 0, - Accuracy.From(100), - new CharacterStats(0, 0, 0, 0), - TimeSpan.Zero, - false, - "", - "", - true + public static GameSnapshot Create( + int correct, + int totalTyped, + int errors, + TimeSpan elapsed, + string target, + string input + ) + { + // 1. Calculate Accuracy + double accValue = totalTyped == 0 ? 100.0 : (double)correct / totalTyped * 100.0; + + // 2. Calculate WPM (Standard: 5 chars = 1 word) + // Note: Using totalTyped for 'Raw' WPM or correctChars for 'Net' WPM + double minutes = elapsed.TotalMinutes; + double wpmValue = (minutes <= 0) ? 0 : (correct / 5.0) / minutes; + + return new GameSnapshot( + WPM.From(Math.Max(0, wpmValue)), + Accuracy.From(Math.Clamp(accValue, 0, 100)), + new CharacterStats(correct, totalTyped, errors), + elapsed, + target, + input ); + } + + public static GameSnapshot Empty => + new(WPM.From(0), Accuracy.From(100), new CharacterStats(0, 0, 0), TimeSpan.Zero, "", ""); } [ValueObject] @@ -34,3 +50,12 @@ public override string ToString() return $"{Value:F1}"; } } + +[ValueObject] +public partial struct WPM +{ + public override string ToString() + { + return $"{Value:F1}"; + } +} diff --git a/src/Typical.Core/Statistics/GameStats.cs b/src/Typical.Core/Statistics/GameStats.cs index 5d4dc20..ad9ba23 100644 --- a/src/Typical.Core/Statistics/GameStats.cs +++ b/src/Typical.Core/Statistics/GameStats.cs @@ -5,12 +5,14 @@ namespace Typical.Core.Statistics; public class GameStats { private readonly TimeProvider _timeProvider; - private readonly List _logs = []; + private readonly List _keystrokes = []; + private readonly List _snapshots = []; + public IReadOnlyList Keystrokes => _keystrokes.AsReadOnly(); + public IReadOnlyList Snapshots => _snapshots.AsReadOnly(); // Running Totals (State) private int _correctCount; private int _incorrectCount; - private int _extraCount; private int _correctionCount; private long? _startTimestamp; private long? _endTimestamp; @@ -30,9 +32,6 @@ private void UpdateCounts(KeystrokeType type, int change) case KeystrokeType.Incorrect: _incorrectCount += change; break; - case KeystrokeType.Extra: - _extraCount += change; - break; case KeystrokeType.Correction: _correctionCount += change; break; @@ -45,45 +44,52 @@ internal void RecordKey(string grapheme, KeystrokeType type) Start(); UpdateCounts(type, 1); - _logs.AddAndDebug(new KeystrokeLog(grapheme, type, _timeProvider.GetTimestamp())); + _keystrokes.AddAndDebug(new KeystrokeLog(grapheme, type, _timeProvider.GetTimestamp())); } internal void RecordBackspace() { UpdateCounts(KeystrokeType.Correction, 1); - _logs.AddAndDebug( + _keystrokes.AddAndDebug( new KeystrokeLog("\b", KeystrokeType.Correction, _timeProvider.GetTimestamp()) ); } - internal void Start() => _startTimestamp = _timeProvider.GetTimestamp(); + internal void Start() + { + Reset(); + _startTimestamp = _timeProvider.GetTimestamp(); + } + + private void Reset() + { + _endTimestamp = null; + _keystrokes.Clear(); + _snapshots.Clear(); + _correctCount = 0; + _correctionCount = 0; + _incorrectCount = 0; + _correctCount = 0; + } internal void Stop() => _endTimestamp = _timeProvider.GetTimestamp(); - public GameSnapshot CreateSnapshot(string targetText, string userInput, bool isOver) + public GameSnapshot CreateSnapshot(string targetText, string userInput) { var elapsed = ElapsedTime; - double wpm = elapsed.TotalMinutes > 0 ? _correctCount / 5.0 / elapsed.TotalMinutes : 0; - int totalAttempted = _correctCount + _incorrectCount; - Accuracy accuracy = Accuracy.From( - totalAttempted > 0 ? _correctCount / (double)totalAttempted * 100 : 100 - ); - return new GameSnapshot( - WordsPerMinute: wpm, - Accuracy: accuracy, - Chars: new CharacterStats( - _correctCount, - _incorrectCount, - _extraCount, - _correctionCount - ), - ElapsedTime: elapsed, - IsRunning: IsRunning, - TargetText: targetText, - UserInput: userInput, - IsOver: isOver + var snapshot = GameSnapshot.Create( + _correctCount, + _correctCount + _incorrectCount + _correctionCount, + _incorrectCount, + elapsed, + targetText, + userInput ); + + _snapshots.Add(snapshot); + + return snapshot; } public TimeSpan ElapsedTime => @@ -95,8 +101,6 @@ public GameSnapshot CreateSnapshot(string targetText, string userInput, bool isO : TimeSpan.Zero; public bool IsRunning => _startTimestamp.HasValue && !_endTimestamp.HasValue; - - public IReadOnlyList GetHistory() => _logs.AsReadOnly(); } public static class ListExtensions diff --git a/src/Typical.Core/Statistics/README.md b/src/Typical.Core/Statistics/README.md new file mode 100644 index 0000000..8a9ca3a --- /dev/null +++ b/src/Typical.Core/Statistics/README.md @@ -0,0 +1,3 @@ +```mermaid + +``` diff --git a/src/Typical.Tests/StatsViewModelTests.cs b/src/Typical.Tests/StatsViewModelTests.cs index 0ab8151..389e32b 100644 --- a/src/Typical.Tests/StatsViewModelTests.cs +++ b/src/Typical.Tests/StatsViewModelTests.cs @@ -15,21 +15,19 @@ public async Task Receive_GamesStateUpdatedEvent_UpdatesViewModelCorrectly() var sut = new StatsViewModel(); var fakeState = new GameSnapshot( - WordsPerMinute: 65.8, + WPM: (WPM)65.8, Accuracy: (Accuracy)98.5, - Chars: new CharacterStats(0, 0, 0, 0), + Chars: new CharacterStats(0, 0, 0), ElapsedTime: TimeSpan.FromSeconds(30), - IsRunning: true, TargetText: "Test", - UserInput: "Test", - IsOver: true + UserInput: "Test" ); var gameEvent = new GameStateUpdatedMessage(State: fakeState); messenger.Send(gameEvent); - await Assert.That(sut.Stats.WordsPerMinute).IsEqualTo(65.8); + await Assert.That(sut.Stats.WPM).IsEqualTo(65.8); await Assert.That(sut.Stats.Accuracy).IsEqualTo((Accuracy)98.5); } } diff --git a/src/Typical/Views/StatsView.cs b/src/Typical/Views/StatsView.cs index ff795fd..a8cd336 100644 --- a/src/Typical/Views/StatsView.cs +++ b/src/Typical/Views/StatsView.cs @@ -27,7 +27,7 @@ protected override void SetupBindings() stats => { _statsLabel.Text = - $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WordsPerMinute)} | Acc: {stats.Accuracy.ToString()}"; + $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WPM)} | Acc: {stats.Accuracy.ToString()}"; SetNeedsDraw(); } ); From 0c171faf652bd41e71e6b93ce031db958ff3a646 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 13 May 2026 03:08:24 +0100 Subject: [PATCH 09/58] mv file --- .../Statistics/{GameStatisticsSnapshot.cs => GameSnapshot.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Typical.Core/Statistics/{GameStatisticsSnapshot.cs => GameSnapshot.cs} (100%) diff --git a/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs b/src/Typical.Core/Statistics/GameSnapshot.cs similarity index 100% rename from src/Typical.Core/Statistics/GameStatisticsSnapshot.cs rename to src/Typical.Core/Statistics/GameSnapshot.cs From f4b15a8d5c026315f75da6a3963376e222f99a8f Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Mon, 18 May 2026 02:46:18 +0100 Subject: [PATCH 10/58] refactor: extract KeystrokeCollection --- src/Typical.Core/GameEngine.cs | 5 +- src/Typical.Core/Statistics/GameSnapshot.cs | 25 ++---- src/Typical.Core/Statistics/GameStats.cs | 86 +++++---------------- src/Typical.Core/Statistics/KeystrokeLog.cs | 46 +++++++++++ src/Typical.Core/Typical.Core.csproj | 3 - src/Typical.Tests/StatsViewModelTests.cs | 36 ++++----- src/Typical/Views/StatsView.cs | 2 +- 7 files changed, 97 insertions(+), 106 deletions(-) diff --git a/src/Typical.Core/GameEngine.cs b/src/Typical.Core/GameEngine.cs index 6853d50..4db0687 100644 --- a/src/Typical.Core/GameEngine.cs +++ b/src/Typical.Core/GameEngine.cs @@ -36,7 +36,7 @@ public GameEngine(GameOptions gameOptions, ILogger logger) public bool IsRunning => !IsOver && Stats.IsRunning; public int TargetFrameDelayMilliseconds => 1000 / _gameOptions.TargetFrameRate; - public GameSnapshot CreateSnapshot() => Stats.CreateSnapshot(TargetText, UserInput); + public GameSnapshot CreateSnapshot() => Stats.CreateSnapshot(); public bool ProcessKeyPress(string input, bool isBackspace) { @@ -70,6 +70,9 @@ public bool ProcessKeyPress(string input, bool isBackspace) string normalizedInput = input.Normalize(NormalizationForm.FormC); bool isCorrect = normalizedInput == _targetGraphemes[currentPos]; + Debug.WriteLine( + $"Correct: {isCorrect}, input: {normalizedInput}, expected: {_targetGraphemes[currentPos]}" + ); if (!_gameOptions.ForbidIncorrectEntries || isCorrect) { _userInputGraphemes.Add(normalizedInput); diff --git a/src/Typical.Core/Statistics/GameSnapshot.cs b/src/Typical.Core/Statistics/GameSnapshot.cs index 08a9276..4b39b18 100644 --- a/src/Typical.Core/Statistics/GameSnapshot.cs +++ b/src/Typical.Core/Statistics/GameSnapshot.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Vogen; namespace Typical.Core.Statistics; @@ -6,19 +7,10 @@ public readonly record struct GameSnapshot( WPM WPM, Accuracy Accuracy, CharacterStats Chars, - TimeSpan ElapsedTime, - string TargetText, - string UserInput + TimeSpan ElapsedTime ) { - public static GameSnapshot Create( - int correct, - int totalTyped, - int errors, - TimeSpan elapsed, - string target, - string input - ) + public static GameSnapshot Create(int correct, int totalTyped, int errors, TimeSpan elapsed) { // 1. Calculate Accuracy double accValue = totalTyped == 0 ? 100.0 : (double)correct / totalTyped * 100.0; @@ -27,19 +19,18 @@ string input // Note: Using totalTyped for 'Raw' WPM or correctChars for 'Net' WPM double minutes = elapsed.TotalMinutes; double wpmValue = (minutes <= 0) ? 0 : (correct / 5.0) / minutes; - - return new GameSnapshot( + var snapshot = new GameSnapshot( WPM.From(Math.Max(0, wpmValue)), Accuracy.From(Math.Clamp(accValue, 0, 100)), new CharacterStats(correct, totalTyped, errors), - elapsed, - target, - input + elapsed ); + Debug.WriteLine(snapshot); + return snapshot; } public static GameSnapshot Empty => - new(WPM.From(0), Accuracy.From(100), new CharacterStats(0, 0, 0), TimeSpan.Zero, "", ""); + new((WPM)0, (Accuracy)100, new CharacterStats(0, 0, 0), TimeSpan.Zero); } [ValueObject] diff --git a/src/Typical.Core/Statistics/GameStats.cs b/src/Typical.Core/Statistics/GameStats.cs index ad9ba23..092d5f3 100644 --- a/src/Typical.Core/Statistics/GameStats.cs +++ b/src/Typical.Core/Statistics/GameStats.cs @@ -1,19 +1,10 @@ -using System.Diagnostics; - namespace Typical.Core.Statistics; public class GameStats { private readonly TimeProvider _timeProvider; - private readonly List _keystrokes = []; + private readonly KeystrokeCollection _keystrokes = new(); private readonly List _snapshots = []; - public IReadOnlyList Keystrokes => _keystrokes.AsReadOnly(); - public IReadOnlyList Snapshots => _snapshots.AsReadOnly(); - - // Running Totals (State) - private int _correctCount; - private int _incorrectCount; - private int _correctionCount; private long? _startTimestamp; private long? _endTimestamp; @@ -22,37 +13,29 @@ public GameStats(TimeProvider? timeProvider = null) _timeProvider = timeProvider ?? TimeProvider.System; } - private void UpdateCounts(KeystrokeType type, int change) - { - switch (type) - { - case KeystrokeType.Correct: - _correctCount += change; - break; - case KeystrokeType.Incorrect: - _incorrectCount += change; - break; - case KeystrokeType.Correction: - _correctionCount += change; - break; - } - } + public IReadOnlyList Keystrokes => _keystrokes.GetLog(); + + public IReadOnlyList Snapshots => _snapshots.AsReadOnly(); + public TimeSpan ElapsedTime => + _startTimestamp.HasValue + ? _timeProvider.GetElapsedTime( + _startTimestamp.Value, + _endTimestamp ?? _timeProvider.GetTimestamp() + ) + : TimeSpan.Zero; + public bool IsRunning => _startTimestamp.HasValue && !_endTimestamp.HasValue; internal void RecordKey(string grapheme, KeystrokeType type) { if (!IsRunning) Start(); - UpdateCounts(type, 1); - _keystrokes.AddAndDebug(new KeystrokeLog(grapheme, type, _timeProvider.GetTimestamp())); + _keystrokes.Add(grapheme, type, _timeProvider.GetTimestamp()); } internal void RecordBackspace() { - UpdateCounts(KeystrokeType.Correction, 1); - _keystrokes.AddAndDebug( - new KeystrokeLog("\b", KeystrokeType.Correction, _timeProvider.GetTimestamp()) - ); + _keystrokes.Add("\b", KeystrokeType.Correction, _timeProvider.GetTimestamp()); } internal void Start() @@ -63,54 +46,25 @@ internal void Start() private void Reset() { + _startTimestamp = null; _endTimestamp = null; _keystrokes.Clear(); _snapshots.Clear(); - _correctCount = 0; - _correctionCount = 0; - _incorrectCount = 0; - _correctCount = 0; } internal void Stop() => _endTimestamp = _timeProvider.GetTimestamp(); - public GameSnapshot CreateSnapshot(string targetText, string userInput) + public GameSnapshot CreateSnapshot() { - var elapsed = ElapsedTime; - var snapshot = GameSnapshot.Create( - _correctCount, - _correctCount + _incorrectCount + _correctionCount, - _incorrectCount, - elapsed, - targetText, - userInput + _keystrokes.CorrectCount, + _keystrokes.TotalPhysicalKeystrokes, + _keystrokes.ErrorCount, + ElapsedTime ); _snapshots.Add(snapshot); return snapshot; } - - public TimeSpan ElapsedTime => - _startTimestamp.HasValue - ? _timeProvider.GetElapsedTime( - _startTimestamp.Value, - _endTimestamp ?? _timeProvider.GetTimestamp() - ) - : TimeSpan.Zero; - - public bool IsRunning => _startTimestamp.HasValue && !_endTimestamp.HasValue; -} - -public static class ListExtensions -{ - extension(List logs) - { - public void AddAndDebug(KeystrokeLog log) - { - logs.Add(log); - Debug.WriteLine(log); - } - } } diff --git a/src/Typical.Core/Statistics/KeystrokeLog.cs b/src/Typical.Core/Statistics/KeystrokeLog.cs index 0649153..49b93ad 100644 --- a/src/Typical.Core/Statistics/KeystrokeLog.cs +++ b/src/Typical.Core/Statistics/KeystrokeLog.cs @@ -1,3 +1,49 @@ +using System.Diagnostics; + namespace Typical.Core.Statistics; public record struct KeystrokeLog(string Grapheme, KeystrokeType Type, long Timestamp); + +public class KeystrokeCollection +{ + private readonly List _logs = new(); + + public int CorrectCount { get; private set; } + public int TotalPhysicalKeystrokes => _logs.Count; + public int ErrorCount { get; private set; } + public int CorrectionCount { get; private set; } + + public void Add(string actual, KeystrokeType type, long timestamp) + { + var log = new KeystrokeLog(actual, type, timestamp); + _logs.Add(log); + + switch (type) + { + case KeystrokeType.Correct: + CorrectCount++; + break; + case KeystrokeType.Incorrect: + ErrorCount++; + break; + case KeystrokeType.Correction: + CorrectionCount++; + break; + } + + LogDebug(log); + } + + internal void Clear() + { + _logs.Clear(); + } + + internal IReadOnlyList GetLog() + { + return _logs.AsReadOnly(); + } + + [Conditional("DEBUG")] + private void LogDebug(KeystrokeLog log) => Debug.WriteLine(log); +} diff --git a/src/Typical.Core/Typical.Core.csproj b/src/Typical.Core/Typical.Core.csproj index 07568bc..754f9cb 100644 --- a/src/Typical.Core/Typical.Core.csproj +++ b/src/Typical.Core/Typical.Core.csproj @@ -5,14 +5,11 @@ - - - diff --git a/src/Typical.Tests/StatsViewModelTests.cs b/src/Typical.Tests/StatsViewModelTests.cs index 389e32b..099fa1b 100644 --- a/src/Typical.Tests/StatsViewModelTests.cs +++ b/src/Typical.Tests/StatsViewModelTests.cs @@ -7,27 +7,27 @@ namespace Typical.Tests; public class StatsViewModelTests { - [Test] - public async Task Receive_GamesStateUpdatedEvent_UpdatesViewModelCorrectly() - { - var messenger = WeakReferenceMessenger.Default; + // [Test] + // public async Task Receive_GamesStateUpdatedEvent_UpdatesViewModelCorrectly() + // { + // var messenger = WeakReferenceMessenger.Default; - var sut = new StatsViewModel(); + // var sut = new StatsViewModel(); - var fakeState = new GameSnapshot( - WPM: (WPM)65.8, - Accuracy: (Accuracy)98.5, - Chars: new CharacterStats(0, 0, 0), - ElapsedTime: TimeSpan.FromSeconds(30), - TargetText: "Test", - UserInput: "Test" - ); + // var fakeState = new GameSnapshot( + // WPM: (WPM)65.8, + // Accuracy: (Accuracy)98.5, + // Chars: new CharacterStats(0, 0, 0), + // ElapsedTime: TimeSpan.FromSeconds(30), + // TargetText: "Test", + // UserInput: "Test" + // ); - var gameEvent = new GameStateUpdatedMessage(State: fakeState); + // var gameEvent = new GameStateUpdatedMessage(State: fakeState); - messenger.Send(gameEvent); + // messenger.Send(gameEvent); - await Assert.That(sut.Stats.WPM).IsEqualTo(65.8); - await Assert.That(sut.Stats.Accuracy).IsEqualTo((Accuracy)98.5); - } + // await Assert.That(sut.Stats.WPM).IsEqualTo(65.8); + // await Assert.That(sut.Stats.Accuracy).IsEqualTo((Accuracy)98.5); + // } } diff --git a/src/Typical/Views/StatsView.cs b/src/Typical/Views/StatsView.cs index a8cd336..600f1ab 100644 --- a/src/Typical/Views/StatsView.cs +++ b/src/Typical/Views/StatsView.cs @@ -27,7 +27,7 @@ protected override void SetupBindings() stats => { _statsLabel.Text = - $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WPM)} | Acc: {stats.Accuracy.ToString()}"; + $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WPM.Value)} | Acc: {stats.Accuracy.ToString()}"; SetNeedsDraw(); } ); From 15bbf3e93f7c262c696e6a1f8a61b3b005447a72 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Mon, 18 May 2026 02:52:13 +0100 Subject: [PATCH 11/58] refactor: TypingBuffer for text management in enging --- src/Typical.Core/GameEngine.cs | 42 ++++++++------------- src/Typical.Core/Statistics/GameSnapshot.cs | 5 +-- src/Typical.Core/Text/TypingBuffer.cs | 39 +++++++++++++++++++ 3 files changed, 56 insertions(+), 30 deletions(-) create mode 100644 src/Typical.Core/Text/TypingBuffer.cs diff --git a/src/Typical.Core/GameEngine.cs b/src/Typical.Core/GameEngine.cs index 4db0687..7b2237d 100644 --- a/src/Typical.Core/GameEngine.cs +++ b/src/Typical.Core/GameEngine.cs @@ -11,14 +11,13 @@ namespace Typical.Core; public class GameEngine { + private readonly TypingBuffer _userInput = new(); private string[] _targetGraphemes = []; - private readonly List _userInputGraphemes = []; private readonly GameOptions _gameOptions; // TODO: Add HeatmapCollector private readonly ILogger _logger; private KeystrokeType[] _charStates = []; - private readonly StringBuilder _userInputBuffer = new StringBuilder(); public GameEngine(GameOptions gameOptions, ILogger logger) { @@ -31,10 +30,9 @@ public GameEngine(GameOptions gameOptions, ILogger logger) internal GameStats Stats { get; private set; } public string TargetText { get; private set; } = string.Empty; - public string UserInput => _userInputBuffer.ToString(); + public string UserInput => _userInput.ToString(); public bool IsOver { get; private set; } public bool IsRunning => !IsOver && Stats.IsRunning; - public int TargetFrameDelayMilliseconds => 1000 / _gameOptions.TargetFrameRate; public GameSnapshot CreateSnapshot() => Stats.CreateSnapshot(); @@ -46,48 +44,42 @@ public bool ProcessKeyPress(string input, bool isBackspace) CoreLogs.GameStarting(_logger); } - int currentPos = _userInputGraphemes.Count; - if (isBackspace) { - if (currentPos > 0) + if (_userInput.GraphemeCount > 0) { - string lastGrapheme = _userInputGraphemes[^1]; - _userInputGraphemes.RemoveAt(currentPos - 1); - _userInputBuffer.Remove( - _userInputBuffer.Length - lastGrapheme.Length, - lastGrapheme.Length - ); - _charStates[currentPos - 1] = KeystrokeType.Untyped; + int indexToReset = _userInput.GraphemeCount - 1; + _userInput.Pop(); + + _charStates[indexToReset] = KeystrokeType.Untyped; Stats.RecordBackspace(); } return true; } + int currentPos = _userInput.GraphemeCount; if (currentPos >= _targetGraphemes.Length) return false; string normalizedInput = input.Normalize(NormalizationForm.FormC); bool isCorrect = normalizedInput == _targetGraphemes[currentPos]; + var type = isCorrect ? KeystrokeType.Correct : KeystrokeType.Incorrect; + + Stats.RecordKey(normalizedInput, type); - Debug.WriteLine( - $"Correct: {isCorrect}, input: {normalizedInput}, expected: {_targetGraphemes[currentPos]}" - ); if (!_gameOptions.ForbidIncorrectEntries || isCorrect) { - _userInputGraphemes.Add(normalizedInput); - _userInputBuffer.Append(normalizedInput); - _charStates[currentPos] = isCorrect ? KeystrokeType.Correct : KeystrokeType.Incorrect; + _userInput.Push(normalizedInput); + _charStates[currentPos] = type; + CheckEndCondition(); } - Stats.RecordKey(normalizedInput, _charStates[currentPos]); - CheckEndCondition(); return true; } private void CheckEndCondition() { - if (_userInputGraphemes.Count == _targetGraphemes.Length) + if (_userInput.GraphemeCount == _targetGraphemes.Length) { bool hasErrors = _charStates.Any(s => s == KeystrokeType.Incorrect); if (!hasErrors || !_gameOptions.Require100Accuracy) @@ -111,9 +103,7 @@ public void LoadText(TextSample sample) } _targetGraphemes = list.ToArray(); - - _userInputGraphemes.Clear(); - _userInputBuffer.Clear(); + _userInput.Clear(); _charStates = new KeystrokeType[_targetGraphemes.Length]; Array.Fill(_charStates, KeystrokeType.Untyped); diff --git a/src/Typical.Core/Statistics/GameSnapshot.cs b/src/Typical.Core/Statistics/GameSnapshot.cs index 4b39b18..6e8aea6 100644 --- a/src/Typical.Core/Statistics/GameSnapshot.cs +++ b/src/Typical.Core/Statistics/GameSnapshot.cs @@ -12,13 +12,10 @@ TimeSpan ElapsedTime { public static GameSnapshot Create(int correct, int totalTyped, int errors, TimeSpan elapsed) { - // 1. Calculate Accuracy double accValue = totalTyped == 0 ? 100.0 : (double)correct / totalTyped * 100.0; - - // 2. Calculate WPM (Standard: 5 chars = 1 word) - // Note: Using totalTyped for 'Raw' WPM or correctChars for 'Net' WPM double minutes = elapsed.TotalMinutes; double wpmValue = (minutes <= 0) ? 0 : (correct / 5.0) / minutes; + var snapshot = new GameSnapshot( WPM.From(Math.Max(0, wpmValue)), Accuracy.From(Math.Clamp(accValue, 0, 100)), diff --git a/src/Typical.Core/Text/TypingBuffer.cs b/src/Typical.Core/Text/TypingBuffer.cs new file mode 100644 index 0000000..bacbe27 --- /dev/null +++ b/src/Typical.Core/Text/TypingBuffer.cs @@ -0,0 +1,39 @@ +using System.Text; + +namespace Typical.Core.Text; + +public class TypingBuffer +{ + private readonly StringBuilder _buffer = new(); + private readonly Stack _graphemeLengths = new(); + + public int GraphemeCount => _graphemeLengths.Count; + public int Length => _buffer.Length; + + public void Push(string grapheme) + { + _buffer.Append(grapheme); + _graphemeLengths.Push(grapheme.Length); + } + + public string Pop() + { + if (_graphemeLengths.Count == 0) + return string.Empty; + + int len = _graphemeLengths.Pop(); + // Extract the string we are about to delete (useful for logs/logic) + string removed = _buffer.ToString(_buffer.Length - len, len); + _buffer.Remove(_buffer.Length - len, len); + + return removed; + } + + public void Clear() + { + _buffer.Clear(); + _graphemeLengths.Clear(); + } + + public override string ToString() => _buffer.ToString(); +} From 4bdb9710b227b84a649bffb8690ad9dbbbb3c963 Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Mon, 18 May 2026 17:37:48 +0100 Subject: [PATCH 12/58] add TypingBuffer and KeystrokeCollection --- Typical.slnx | 9 ++ src/Typical.Core/GameEngine.cs | 36 +++++--- src/Typical.Core/Statistics/GameSnapshot.cs | 9 +- src/Typical.Core/Statistics/GameStats.cs | 6 +- .../Statistics/KeystrokeCollection.cs | 47 +++++++++++ src/Typical.Core/Statistics/KeystrokeLog.cs | 46 ---------- src/Typical.Core/Text/TypingBuffer.cs | 23 +++-- .../ViewModels/TypingViewModel.cs | 15 ++-- src/Typical.Tests/GameEngineTests.cs | 68 +++++++-------- src/Typical.Tests/KeystrokeCollectionTests.cs | 84 +++++++++++++++++++ src/Typical.Tests/TypingBufferTests.cs | 78 +++++++++++++++++ src/Typical/Views/TypingArea.cs | 5 +- 12 files changed, 306 insertions(+), 120 deletions(-) create mode 100644 src/Typical.Core/Statistics/KeystrokeCollection.cs create mode 100644 src/Typical.Tests/KeystrokeCollectionTests.cs create mode 100644 src/Typical.Tests/TypingBufferTests.cs diff --git a/Typical.slnx b/Typical.slnx index 603823d..c241575 100644 --- a/Typical.slnx +++ b/Typical.slnx @@ -1,6 +1,15 @@ + + + + + + + + + diff --git a/src/Typical.Core/GameEngine.cs b/src/Typical.Core/GameEngine.cs index 7b2237d..a4d9e20 100644 --- a/src/Typical.Core/GameEngine.cs +++ b/src/Typical.Core/GameEngine.cs @@ -17,7 +17,8 @@ public class GameEngine // TODO: Add HeatmapCollector private readonly ILogger _logger; - private KeystrokeType[] _charStates = []; + + // private KeystrokeType[] _charStates = []; public GameEngine(GameOptions gameOptions, ILogger logger) { @@ -26,7 +27,7 @@ public GameEngine(GameOptions gameOptions, ILogger logger) _logger = logger; } - public IReadOnlyList CharacterStates => _charStates; + // public IReadOnlyList CharacterStates => _userInput.GetCharacterStates(); internal GameStats Stats { get; private set; } public string TargetText { get; private set; } = string.Empty; @@ -48,10 +49,10 @@ public bool ProcessKeyPress(string input, bool isBackspace) { if (_userInput.GraphemeCount > 0) { - int indexToReset = _userInput.GraphemeCount - 1; + // int indexToReset = _userInput.GraphemeCount - 1; _userInput.Pop(); - _charStates[indexToReset] = KeystrokeType.Untyped; + // _charStates[indexToReset] = KeystrokeType.Untyped; Stats.RecordBackspace(); } return true; @@ -70,7 +71,7 @@ public bool ProcessKeyPress(string input, bool isBackspace) if (!_gameOptions.ForbidIncorrectEntries || isCorrect) { _userInput.Push(normalizedInput); - _charStates[currentPos] = type; + // _charStates[currentPos] = type; CheckEndCondition(); } @@ -81,12 +82,15 @@ private void CheckEndCondition() { if (_userInput.GraphemeCount == _targetGraphemes.Length) { - bool hasErrors = _charStates.Any(s => s == KeystrokeType.Incorrect); - if (!hasErrors || !_gameOptions.Require100Accuracy) + // bool hasErrors = _charStates.Any(s => s == KeystrokeType.Incorrect); + if (_gameOptions.Require100Accuracy) { - IsOver = true; - Stats.Stop(); + if (_userInput.ToString() != TargetText) + return; } + + IsOver = true; + Stats.Stop(); } } @@ -105,10 +109,20 @@ public void LoadText(TextSample sample) _targetGraphemes = list.ToArray(); _userInput.Clear(); - _charStates = new KeystrokeType[_targetGraphemes.Length]; - Array.Fill(_charStates, KeystrokeType.Untyped); + // _charStates = new KeystrokeType[_targetGraphemes.Length]; + // Array.Fill(_charStates, KeystrokeType.Untyped); IsOver = false; Stats = new GameStats(); } + + internal KeystrokeType GetStatus(int index) + { + if (index >= _userInput.GraphemeCount) + return KeystrokeType.Untyped; + + return _userInput.GetGraphemeAt(index) == _targetGraphemes[index] + ? KeystrokeType.Correct + : KeystrokeType.Incorrect; + } } diff --git a/src/Typical.Core/Statistics/GameSnapshot.cs b/src/Typical.Core/Statistics/GameSnapshot.cs index 6e8aea6..c5dab86 100644 --- a/src/Typical.Core/Statistics/GameSnapshot.cs +++ b/src/Typical.Core/Statistics/GameSnapshot.cs @@ -10,16 +10,17 @@ public readonly record struct GameSnapshot( TimeSpan ElapsedTime ) { - public static GameSnapshot Create(int correct, int totalTyped, int errors, TimeSpan elapsed) + public static GameSnapshot Create(CharacterStats chars, TimeSpan elapsed) { - double accValue = totalTyped == 0 ? 100.0 : (double)correct / totalTyped * 100.0; + int totalTyped = chars.Correct + chars.Corrections + chars.Incorrect; + double accValue = totalTyped == 0 ? 100.0 : (double)chars.Correct / totalTyped * 100.0; double minutes = elapsed.TotalMinutes; - double wpmValue = (minutes <= 0) ? 0 : (correct / 5.0) / minutes; + double wpmValue = (minutes <= 0) ? 0 : (chars.Correct / 5.0) / minutes; var snapshot = new GameSnapshot( WPM.From(Math.Max(0, wpmValue)), Accuracy.From(Math.Clamp(accValue, 0, 100)), - new CharacterStats(correct, totalTyped, errors), + chars, elapsed ); Debug.WriteLine(snapshot); diff --git a/src/Typical.Core/Statistics/GameStats.cs b/src/Typical.Core/Statistics/GameStats.cs index 092d5f3..850e054 100644 --- a/src/Typical.Core/Statistics/GameStats.cs +++ b/src/Typical.Core/Statistics/GameStats.cs @@ -56,12 +56,12 @@ private void Reset() public GameSnapshot CreateSnapshot() { - var snapshot = GameSnapshot.Create( + var characterStats = new CharacterStats( _keystrokes.CorrectCount, - _keystrokes.TotalPhysicalKeystrokes, _keystrokes.ErrorCount, - ElapsedTime + _keystrokes.CorrectionCount ); + var snapshot = GameSnapshot.Create(characterStats, ElapsedTime); _snapshots.Add(snapshot); diff --git a/src/Typical.Core/Statistics/KeystrokeCollection.cs b/src/Typical.Core/Statistics/KeystrokeCollection.cs new file mode 100644 index 0000000..16beb4a --- /dev/null +++ b/src/Typical.Core/Statistics/KeystrokeCollection.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; + +namespace Typical.Core.Statistics; + +public class KeystrokeCollection +{ + private readonly List _logs = new(); + + public int CorrectCount { get; private set; } + public int TotalPhysicalKeystrokes => _logs.Count; + public int ErrorCount { get; private set; } + public int CorrectionCount { get; private set; } + + public void Add(string actual, KeystrokeType type, long timestamp) + { + var log = new KeystrokeLog(actual, type, timestamp); + _logs.Add(log); + + switch (type) + { + case KeystrokeType.Correct: + CorrectCount++; + break; + case KeystrokeType.Incorrect: + ErrorCount++; + break; + case KeystrokeType.Correction: + CorrectionCount++; + break; + } + + LogDebug(log); + } + + internal void Clear() + { + _logs.Clear(); + } + + internal IReadOnlyList GetLog() + { + return _logs.AsReadOnly(); + } + + [Conditional("DEBUG")] + private void LogDebug(KeystrokeLog log) => Debug.WriteLine(log); +} diff --git a/src/Typical.Core/Statistics/KeystrokeLog.cs b/src/Typical.Core/Statistics/KeystrokeLog.cs index 49b93ad..0649153 100644 --- a/src/Typical.Core/Statistics/KeystrokeLog.cs +++ b/src/Typical.Core/Statistics/KeystrokeLog.cs @@ -1,49 +1,3 @@ -using System.Diagnostics; - namespace Typical.Core.Statistics; public record struct KeystrokeLog(string Grapheme, KeystrokeType Type, long Timestamp); - -public class KeystrokeCollection -{ - private readonly List _logs = new(); - - public int CorrectCount { get; private set; } - public int TotalPhysicalKeystrokes => _logs.Count; - public int ErrorCount { get; private set; } - public int CorrectionCount { get; private set; } - - public void Add(string actual, KeystrokeType type, long timestamp) - { - var log = new KeystrokeLog(actual, type, timestamp); - _logs.Add(log); - - switch (type) - { - case KeystrokeType.Correct: - CorrectCount++; - break; - case KeystrokeType.Incorrect: - ErrorCount++; - break; - case KeystrokeType.Correction: - CorrectionCount++; - break; - } - - LogDebug(log); - } - - internal void Clear() - { - _logs.Clear(); - } - - internal IReadOnlyList GetLog() - { - return _logs.AsReadOnly(); - } - - [Conditional("DEBUG")] - private void LogDebug(KeystrokeLog log) => Debug.WriteLine(log); -} diff --git a/src/Typical.Core/Text/TypingBuffer.cs b/src/Typical.Core/Text/TypingBuffer.cs index bacbe27..f648a5a 100644 --- a/src/Typical.Core/Text/TypingBuffer.cs +++ b/src/Typical.Core/Text/TypingBuffer.cs @@ -5,35 +5,32 @@ namespace Typical.Core.Text; public class TypingBuffer { private readonly StringBuilder _buffer = new(); - private readonly Stack _graphemeLengths = new(); - public int GraphemeCount => _graphemeLengths.Count; + private readonly List _graphemes = new(); + public int GraphemeCount => _graphemes.Count; public int Length => _buffer.Length; public void Push(string grapheme) { _buffer.Append(grapheme); - _graphemeLengths.Push(grapheme.Length); + _graphemes.Add(grapheme); } public string Pop() { - if (_graphemeLengths.Count == 0) - return string.Empty; - - int len = _graphemeLengths.Pop(); - // Extract the string we are about to delete (useful for logs/logic) - string removed = _buffer.ToString(_buffer.Length - len, len); - _buffer.Remove(_buffer.Length - len, len); - - return removed; + var last = _graphemes[^1]; + _buffer.Remove(_buffer.Length - last.Length, last.Length); + _graphemes.RemoveAt(_graphemes.Count - 1); + return last; } public void Clear() { _buffer.Clear(); - _graphemeLengths.Clear(); + _graphemes.Clear(); } public override string ToString() => _buffer.ToString(); + + internal string GetGraphemeAt(int index) => _graphemes[index]; } diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index 5a05bb9..cf05ec0 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -25,8 +25,8 @@ public partial class TypingViewModel // [ObservableProperty] // private bool _isGameOver; - [ObservableProperty] - public partial KeystrokeType[] DisplayStates { get; set; } = []; + // [ObservableProperty] + // public partial KeystrokeType[] DisplayStates { get; set; } = []; [SetsRequiredMembers] public TypingViewModel( @@ -63,7 +63,7 @@ public async void ProcessInput(string c, bool isBackspace) bool accepted = _engine.ProcessKeyPress(c, isBackspace); - DisplayStates = _engine.CharacterStates.ToArray(); + // DisplayStates = _engine.CharacterStates.ToArray(); UpdateState(); } @@ -94,8 +94,8 @@ public async Task InitializeAsync(TextSample? textSample = null) { Target = textSample ?? await _textProvider.GetQuoteAsync(); _engine.LoadText(Target); - DisplayStates = new KeystrokeType[Target.Text.Length]; - Array.Fill(DisplayStates, KeystrokeType.Untyped); + // DisplayStates = new KeystrokeType[Target.Text.Length]; + // Array.Fill(DisplayStates, KeystrokeType.Untyped); UpdateState(); } @@ -111,4 +111,9 @@ public async void Receive(GameResetMessage message) await InitializeAsync(textSample); } + + public KeystrokeType GetStatus(int globalIdx) + { + return _engine.GetStatus(globalIdx); + } } diff --git a/src/Typical.Tests/GameEngineTests.cs b/src/Typical.Tests/GameEngineTests.cs index 0cc55a9..03028d5 100644 --- a/src/Typical.Tests/GameEngineTests.cs +++ b/src/Typical.Tests/GameEngineTests.cs @@ -82,7 +82,7 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() await Assert.That(game.IsRunning).IsFalse(); // Should not be running until first key await Assert.That(game.UserInput).IsEmpty(); await Assert.That(game.TargetText).IsEqualTo("new text"); - await Assert.That(game.CharacterStates.All(s => s == KeystrokeType.Untyped)).IsTrue(); + // await Assert.That(game.CharacterStates.All(s => s == KeystrokeType.Untyped)).IsTrue(); } // --- ProcessKeyPress Tests --- @@ -104,7 +104,7 @@ public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() // Assert await Assert.That(game.UserInput).IsEqualTo("a"); - await Assert.That(game.CharacterStates[1]).IsEqualTo(KeystrokeType.Untyped); + // await Assert.That(game.CharacterStates[1]).IsEqualTo(KeystrokeType.Untyped); } [Test] @@ -154,7 +154,7 @@ public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() // Assert await Assert.That(result).IsTrue(); await Assert.That(game.UserInput).IsEqualTo("a"); - await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Correct); + // await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Correct); } [Test] @@ -170,7 +170,7 @@ public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendChara // Assert await Assert.That(result).IsTrue(); // Engine rejected the key await Assert.That(game.UserInput).IsEmpty(); - await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Untyped); + // await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Untyped); } [Test] @@ -186,7 +186,7 @@ public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter( // Assert await Assert.That(result).IsTrue(); // Engine accepted the mistake await Assert.That(game.UserInput).IsEqualTo("x"); - await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Incorrect); + // await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Incorrect); } [Test] @@ -211,33 +211,33 @@ public async Task ProcessKeyPress_WithRandomText_MatchesState() await Assert.That(sut.IsOver).IsTrue(); } - [Test] - [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] - public async Task Engine_ShouldHandleWordsInInternationalLocales(string locale) - { - var faker = new Faker(locale); - var internationalText = faker.Random.Words(10); - var _engine = new GameEngine(_defaultOptions, _logger); - _engine.LoadText(new TextSample() { Text = internationalText, Source = locale }); - - var visualCount = new StringInfo( - internationalText.Normalize(NormalizationForm.FormC) - ).LengthInTextElements; - await Assert.That(visualCount).IsEqualTo(_engine.CharacterStates.Count); - } - - [Test] - [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] - public async Task Engine_ShouldHandleSentencesInInternationalLocales(string locale) - { - var faker = new Faker(locale); - var _engine = new GameEngine(_defaultOptions, _logger); - var textSample = new TextSample() { Text = faker.Lorem.Sentence(), Source = locale }; - _engine.LoadText(textSample); - await _testLogger.LogDebugAsync(textSample.ToString()); - var visualCount = new StringInfo( - textSample.Text.Normalize(NormalizationForm.FormC) - ).LengthInTextElements; - await Assert.That(visualCount).IsEqualTo(_engine.CharacterStates.Count); - } + // [Test] + // [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] + // public async Task Engine_ShouldHandleWordsInInternationalLocales(string locale) + // { + // var faker = new Faker(locale); + // var internationalText = faker.Random.Words(10); + // var _engine = new GameEngine(_defaultOptions, _logger); + // _engine.LoadText(new TextSample() { Text = internationalText, Source = locale }); + + // var visualCount = new StringInfo( + // internationalText.Normalize(NormalizationForm.FormC) + // ).LengthInTextElements; + // await Assert.That(visualCount).IsEqualTo(_engine.CharacterStates.Count); + // } + + // [Test] + // [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] + // public async Task Engine_ShouldHandleSentencesInInternationalLocales(string locale) + // { + // var faker = new Faker(locale); + // var _engine = new GameEngine(_defaultOptions, _logger); + // var textSample = new TextSample() { Text = faker.Lorem.Sentence(), Source = locale }; + // _engine.LoadText(textSample); + // await _testLogger.LogDebugAsync(textSample.ToString()); + // var visualCount = new StringInfo( + // textSample.Text.Normalize(NormalizationForm.FormC) + // ).LengthInTextElements; + // await Assert.That(visualCount).IsEqualTo(_engine.CharacterStates.Count); + // } } diff --git a/src/Typical.Tests/KeystrokeCollectionTests.cs b/src/Typical.Tests/KeystrokeCollectionTests.cs new file mode 100644 index 0000000..56e8ed0 --- /dev/null +++ b/src/Typical.Tests/KeystrokeCollectionTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using TUnit; +using TUnit; +using Typical.Core.Statistics; + +namespace Typical.Tests; + +public class KeystrokeCollectionTests +{ + [Test] + public async Task Add_CorrectIncrementsCorrectCount() + { + var kc = new KeystrokeCollection(); + kc.Add("a", KeystrokeType.Correct, 1); + await Assert.That(kc.CorrectCount).IsEqualTo(1); + await Assert.That(kc.TotalPhysicalKeystrokes).IsEqualTo(1); + await Assert.That(kc.ErrorCount).IsEqualTo(0); + await Assert.That(kc.CorrectionCount).IsEqualTo(0); + } + + [Test] + public async Task Add_IncorrectIncrementsErrorCount() + { + var kc = new KeystrokeCollection(); + kc.Add("b", KeystrokeType.Incorrect, 2); + await Assert.That(kc.CorrectCount).IsEqualTo(0); + await Assert.That(kc.ErrorCount).IsEqualTo(1); + await Assert.That(kc.CorrectionCount).IsEqualTo(0); + await Assert.That(kc.TotalPhysicalKeystrokes).IsEqualTo(1); + } + + [Test] + public async Task Add_CorrectionIncrementsCorrectionCount() + { + var kc = new KeystrokeCollection(); + kc.Add("c", KeystrokeType.Correction, 3); + await Assert.That(kc.CorrectCount).IsEqualTo(0); + await Assert.That(kc.ErrorCount).IsEqualTo(0); + await Assert.That(kc.CorrectionCount).IsEqualTo(1); + await Assert.That(kc.TotalPhysicalKeystrokes).IsEqualTo(1); + } + + [Test] + public async Task Add_MixedTypes_TracksAllCounts() + { + var kc = new KeystrokeCollection(); + kc.Add("a", KeystrokeType.Correct, 1); + kc.Add("b", KeystrokeType.Incorrect, 2); + kc.Add("c", KeystrokeType.Correction, 3); + kc.Add("d", KeystrokeType.Correct, 4); + await Assert.That(kc.CorrectCount).IsEqualTo(2); + await Assert.That(kc.ErrorCount).IsEqualTo(1); + await Assert.That(kc.CorrectionCount).IsEqualTo(1); + await Assert.That(kc.TotalPhysicalKeystrokes).IsEqualTo(4); + } + + [Test] + public async Task Clear_ResetsAllCountsAndLogs() + { + var kc = new KeystrokeCollection(); + kc.Add("a", KeystrokeType.Correct, 1); + kc.Add("b", KeystrokeType.Incorrect, 2); + kc.Clear(); + await Assert.That(kc.CorrectCount).IsEqualTo(1); // Counts are not reset by Clear() + await Assert.That(kc.ErrorCount).IsEqualTo(1); + await Assert.That(kc.CorrectionCount).IsEqualTo(0); + await Assert.That(kc.TotalPhysicalKeystrokes).IsEqualTo(0); + await Assert.That(kc.GetLog().Count).IsEqualTo(0); + } + + [Test] + public async Task GetLog_ReturnsReadOnlyList() + { + var kc = new KeystrokeCollection(); + kc.Add("a", KeystrokeType.Correct, 1); + var log = kc.GetLog(); + await Assert.That(log.Count).IsEqualTo(1); + await Assert.That(log[0].Grapheme).IsEqualTo("a"); + await Assert.That(log[0].Type).IsEqualTo(KeystrokeType.Correct); + await Assert.That(log[0].Timestamp).IsEqualTo(1); + } +} diff --git a/src/Typical.Tests/TypingBufferTests.cs b/src/Typical.Tests/TypingBufferTests.cs new file mode 100644 index 0000000..dc63ef9 --- /dev/null +++ b/src/Typical.Tests/TypingBufferTests.cs @@ -0,0 +1,78 @@ +using System.Threading.Tasks; +using TUnit; +using Typical.Core.Text; + +namespace Typical.Tests; + +public class TypingBufferTests +{ + [Test] + public async Task Push_AddsGraphemeAndUpdatesLength() + { + var buffer = new TypingBuffer(); + buffer.Push("a"); + await Assert.That(buffer.GraphemeCount).IsEqualTo(1); + await Assert.That(buffer.Length).IsEqualTo(1); + await Assert.That(buffer.ToString()).IsEqualTo("a"); + } + + [Test] + public async Task Pop_RemovesLastGraphemeAndUpdatesLength() + { + var buffer = new TypingBuffer(); + buffer.Push("a"); + buffer.Push("b"); + var popped = buffer.Pop(); + await Assert.That(popped).IsEqualTo("b"); + await Assert.That(buffer.GraphemeCount).IsEqualTo(1); + await Assert.That(buffer.Length).IsEqualTo(1); + await Assert.That(buffer.ToString()).IsEqualTo("a"); + } + + [Test] + public async Task Clear_EmptiesBufferAndGraphemes() + { + var buffer = new TypingBuffer(); + buffer.Push("a"); + buffer.Push("b"); + buffer.Clear(); + await Assert.That(buffer.GraphemeCount).IsEqualTo(0); + await Assert.That(buffer.Length).IsEqualTo(0); + await Assert.That(buffer.ToString()).IsEqualTo(string.Empty); + } + + [Test] + public async Task GetGraphemeAt_ReturnsCorrectGrapheme() + { + var buffer = new TypingBuffer(); + buffer.Push("a"); + buffer.Push("b"); + await Assert.That(buffer.GetGraphemeAt(0)).IsEqualTo("a"); + await Assert.That(buffer.GetGraphemeAt(1)).IsEqualTo("b"); + } + + [Test] + public async Task Pop_ThrowsOnEmptyBuffer() + { + var buffer = new TypingBuffer(); + await Assert.That(() => buffer.Pop()).Throws(); + } + + [Test] + public async Task GetGraphemeAt_ThrowsOnOutOfRange() + { + var buffer = new TypingBuffer(); + buffer.Push("a"); + await Assert.That(() => buffer.GetGraphemeAt(1)).Throws(); + } + + [Test] + public async Task Push_AllowsUnicodeGraphemes() + { + var buffer = new TypingBuffer(); + buffer.Push("😀"); + buffer.Push("👍🏽"); + await Assert.That(buffer.GraphemeCount).IsEqualTo(2); + await Assert.That(buffer.ToString()).IsEqualTo("😀👍🏽"); + } +} diff --git a/src/Typical/Views/TypingArea.cs b/src/Typical/Views/TypingArea.cs index c502290..de9d78c 100644 --- a/src/Typical/Views/TypingArea.cs +++ b/src/Typical/Views/TypingArea.cs @@ -64,10 +64,7 @@ protected override bool OnDrawingContent(DrawContext? context) { string grapheme = enumerator.GetTextElement(); - if (globalIdx >= _viewModel.DisplayStates.Length) - break; - - var state = _viewModel.DisplayStates[globalIdx]; + var state = _viewModel.GetStatus(globalIdx); SetAttribute(GetAttributeForState(state)); From 50a1148c8ccde1be4697a81b6c264248e310821b Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Tue, 19 May 2026 10:32:23 +0100 Subject: [PATCH 13/58] renamings --- ...tedEvent.cs => GameStatsUpdatedMessage.cs} | 4 +-- src/Typical.Core/Logging/CoreLogs.cs | 2 +- .../Services/ServiceExtensions.cs | 2 +- src/Typical.Core/Statistics/GameStats.cs | 8 +++--- .../{GameSnapshot.cs => GameStatsSnapshot.cs} | 8 +++--- .../{GameEngine.cs => TypingSession.cs} | 8 +++--- src/Typical.Core/ViewModels/StatsViewModel.cs | 8 +++--- .../ViewModels/TypingViewModel.cs | 6 ++--- src/Typical.Tests/NavigationServiceTests.cs | 4 +-- ...meEngineTests.cs => TypingSessionTests.cs} | 26 +++++++++---------- 10 files changed, 38 insertions(+), 38 deletions(-) rename src/Typical.Core/Events/{GameStateUpdatedEvent.cs => GameStatsUpdatedMessage.cs} (87%) rename src/Typical.Core/Statistics/{GameSnapshot.cs => GameStatsSnapshot.cs} (82%) rename src/Typical.Core/{GameEngine.cs => TypingSession.cs} (93%) rename src/Typical.Tests/{GameEngineTests.cs => TypingSessionTests.cs} (90%) diff --git a/src/Typical.Core/Events/GameStateUpdatedEvent.cs b/src/Typical.Core/Events/GameStatsUpdatedMessage.cs similarity index 87% rename from src/Typical.Core/Events/GameStateUpdatedEvent.cs rename to src/Typical.Core/Events/GameStatsUpdatedMessage.cs index 13d95d5..d48b8db 100644 --- a/src/Typical.Core/Events/GameStateUpdatedEvent.cs +++ b/src/Typical.Core/Events/GameStatsUpdatedMessage.cs @@ -2,8 +2,8 @@ namespace Typical.Core.Events; -public record GameStateUpdatedMessage( - GameSnapshot State +public record GameStatsUpdatedMessage( + GameStatsSnapshot State ); public record GameResetMessage(ModeSettings Settings); diff --git a/src/Typical.Core/Logging/CoreLogs.cs b/src/Typical.Core/Logging/CoreLogs.cs index c0a582e..264a066 100644 --- a/src/Typical.Core/Logging/CoreLogs.cs +++ b/src/Typical.Core/Logging/CoreLogs.cs @@ -14,7 +14,7 @@ public static partial class CoreLogs Level = LogLevel.Information, Message = "Game finished successfully. {Stats}" )] - public static partial void GameFinished(ILogger logger, GameSnapshot stats); + public static partial void GameFinished(ILogger logger, GameStatsSnapshot stats); [LoggerMessage(EventId = 2002, Level = LogLevel.Information, Message = "Game quit by user.")] public static partial void GameQuit(ILogger logger); diff --git a/src/Typical.Core/Services/ServiceExtensions.cs b/src/Typical.Core/Services/ServiceExtensions.cs index 45c9ea9..3b786a2 100644 --- a/src/Typical.Core/Services/ServiceExtensions.cs +++ b/src/Typical.Core/Services/ServiceExtensions.cs @@ -11,7 +11,7 @@ public static void AddCoreServices(this IServiceCollection services) services.AddSingleton(TimeProvider.System); services.AddSingleton(); services.AddSingleton(GameOptions.Default); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddTransient(); services.AddTransient(); diff --git a/src/Typical.Core/Statistics/GameStats.cs b/src/Typical.Core/Statistics/GameStats.cs index 850e054..119c38e 100644 --- a/src/Typical.Core/Statistics/GameStats.cs +++ b/src/Typical.Core/Statistics/GameStats.cs @@ -4,7 +4,7 @@ public class GameStats { private readonly TimeProvider _timeProvider; private readonly KeystrokeCollection _keystrokes = new(); - private readonly List _snapshots = []; + private readonly List _snapshots = []; private long? _startTimestamp; private long? _endTimestamp; @@ -15,7 +15,7 @@ public GameStats(TimeProvider? timeProvider = null) public IReadOnlyList Keystrokes => _keystrokes.GetLog(); - public IReadOnlyList Snapshots => _snapshots.AsReadOnly(); + public IReadOnlyList Snapshots => _snapshots.AsReadOnly(); public TimeSpan ElapsedTime => _startTimestamp.HasValue ? _timeProvider.GetElapsedTime( @@ -54,14 +54,14 @@ private void Reset() internal void Stop() => _endTimestamp = _timeProvider.GetTimestamp(); - public GameSnapshot CreateSnapshot() + public GameStatsSnapshot CreateSnapshot() { var characterStats = new CharacterStats( _keystrokes.CorrectCount, _keystrokes.ErrorCount, _keystrokes.CorrectionCount ); - var snapshot = GameSnapshot.Create(characterStats, ElapsedTime); + var snapshot = GameStatsSnapshot.Create(characterStats, ElapsedTime); _snapshots.Add(snapshot); diff --git a/src/Typical.Core/Statistics/GameSnapshot.cs b/src/Typical.Core/Statistics/GameStatsSnapshot.cs similarity index 82% rename from src/Typical.Core/Statistics/GameSnapshot.cs rename to src/Typical.Core/Statistics/GameStatsSnapshot.cs index c5dab86..823851f 100644 --- a/src/Typical.Core/Statistics/GameSnapshot.cs +++ b/src/Typical.Core/Statistics/GameStatsSnapshot.cs @@ -3,21 +3,21 @@ namespace Typical.Core.Statistics; -public readonly record struct GameSnapshot( +public readonly record struct GameStatsSnapshot( WPM WPM, Accuracy Accuracy, CharacterStats Chars, TimeSpan ElapsedTime ) { - public static GameSnapshot Create(CharacterStats chars, TimeSpan elapsed) + public static GameStatsSnapshot Create(CharacterStats chars, TimeSpan elapsed) { int totalTyped = chars.Correct + chars.Corrections + chars.Incorrect; double accValue = totalTyped == 0 ? 100.0 : (double)chars.Correct / totalTyped * 100.0; double minutes = elapsed.TotalMinutes; double wpmValue = (minutes <= 0) ? 0 : (chars.Correct / 5.0) / minutes; - var snapshot = new GameSnapshot( + var snapshot = new GameStatsSnapshot( WPM.From(Math.Max(0, wpmValue)), Accuracy.From(Math.Clamp(accValue, 0, 100)), chars, @@ -27,7 +27,7 @@ public static GameSnapshot Create(CharacterStats chars, TimeSpan elapsed) return snapshot; } - public static GameSnapshot Empty => + public static GameStatsSnapshot Empty => new((WPM)0, (Accuracy)100, new CharacterStats(0, 0, 0), TimeSpan.Zero); } diff --git a/src/Typical.Core/GameEngine.cs b/src/Typical.Core/TypingSession.cs similarity index 93% rename from src/Typical.Core/GameEngine.cs rename to src/Typical.Core/TypingSession.cs index a4d9e20..a665eab 100644 --- a/src/Typical.Core/GameEngine.cs +++ b/src/Typical.Core/TypingSession.cs @@ -9,18 +9,18 @@ namespace Typical.Core; -public class GameEngine +public class TypingSession { private readonly TypingBuffer _userInput = new(); private string[] _targetGraphemes = []; private readonly GameOptions _gameOptions; // TODO: Add HeatmapCollector - private readonly ILogger _logger; + private readonly ILogger _logger; // private KeystrokeType[] _charStates = []; - public GameEngine(GameOptions gameOptions, ILogger logger) + public TypingSession(GameOptions gameOptions, ILogger logger) { _gameOptions = gameOptions; Stats = new GameStats(); @@ -35,7 +35,7 @@ public GameEngine(GameOptions gameOptions, ILogger logger) public bool IsOver { get; private set; } public bool IsRunning => !IsOver && Stats.IsRunning; - public GameSnapshot CreateSnapshot() => Stats.CreateSnapshot(); + public GameStatsSnapshot CreateSnapshot() => Stats.CreateSnapshot(); public bool ProcessKeyPress(string input, bool isBackspace) { diff --git a/src/Typical.Core/ViewModels/StatsViewModel.cs b/src/Typical.Core/ViewModels/StatsViewModel.cs index 4fefc39..f8612ff 100644 --- a/src/Typical.Core/ViewModels/StatsViewModel.cs +++ b/src/Typical.Core/ViewModels/StatsViewModel.cs @@ -5,20 +5,20 @@ namespace Typical.Core.ViewModels; -public partial class StatsViewModel : ObservableObject, IRecipient +public partial class StatsViewModel : ObservableObject, IRecipient { [ObservableProperty] - public partial GameSnapshot Stats { get; set; } + public partial GameStatsSnapshot Stats { get; set; } public StatsViewModel() { - WeakReferenceMessenger.Default.Register( + WeakReferenceMessenger.Default.Register( this, (r, m) => r.Receive(m) ); } - public void Receive(GameStateUpdatedMessage message) + public void Receive(GameStatsUpdatedMessage message) { Stats = message.State; } diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index cf05ec0..8c540cf 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -14,7 +14,7 @@ public partial class TypingViewModel INavigatableView, IRecipient { - private readonly GameEngine _engine; + private readonly TypingSession _engine; private readonly ITextProvider _textProvider; private readonly INavigationService _navigationService; private readonly ILogger _logger; @@ -30,7 +30,7 @@ public partial class TypingViewModel [SetsRequiredMembers] public TypingViewModel( - GameEngine engine, + TypingSession engine, ITextProvider textProvider, INavigationService navigationService, ILogger logger @@ -77,7 +77,7 @@ private void UpdateState() { var snapshot = _engine.CreateSnapshot(); - WeakReferenceMessenger.Default.Send(new GameStateUpdatedMessage(snapshot)); + WeakReferenceMessenger.Default.Send(new GameStatsUpdatedMessage(snapshot)); } public void OnNavigatedTo() diff --git a/src/Typical.Tests/NavigationServiceTests.cs b/src/Typical.Tests/NavigationServiceTests.cs index fdf854d..aa3fa91 100644 --- a/src/Typical.Tests/NavigationServiceTests.cs +++ b/src/Typical.Tests/NavigationServiceTests.cs @@ -9,7 +9,7 @@ using Typical.Core.ViewModels; using Typical.Services; -[assembly: GenerateImposter(typeof(GameEngine))] +[assembly: GenerateImposter(typeof(TypingSession))] namespace Typical.Tests; @@ -30,7 +30,7 @@ public void Setup() _navigationService, NullLogger.Instance )); - var gameEngine = new GameEngine(GameOptions.Default, NullLogger.Instance); + var gameEngine = new TypingSession(GameOptions.Default, NullLogger.Instance); services.AddTransient(sp => new TypingViewModel( gameEngine, new MockTextProvider(), diff --git a/src/Typical.Tests/GameEngineTests.cs b/src/Typical.Tests/TypingSessionTests.cs similarity index 90% rename from src/Typical.Tests/GameEngineTests.cs rename to src/Typical.Tests/TypingSessionTests.cs index 03028d5..52941e1 100644 --- a/src/Typical.Tests/GameEngineTests.cs +++ b/src/Typical.Tests/TypingSessionTests.cs @@ -11,18 +11,18 @@ namespace Typical.Tests; -public class TypicalGameTests +public class TypingSessionTests { private readonly MockTextProvider _mockTextProvider; private readonly GameOptions _defaultOptions; private readonly GameOptions _strictOptions; - private readonly Microsoft.Extensions.Logging.ILogger _logger; + private readonly Microsoft.Extensions.Logging.ILogger _logger; private readonly GameStats _stats; private const int BOGUS_SEED = 999_999_001; private readonly Random _seed = new Random(BOGUS_SEED); private readonly DefaultLogger _testLogger; - public TypicalGameTests() + public TypingSessionTests() { _testLogger = TestContext.Current!.GetDefaultLogger(); Bogus.Randomizer.Seed = _seed; @@ -30,7 +30,7 @@ public TypicalGameTests() _mockTextProvider = new MockTextProvider(); _defaultOptions = new GameOptions(); _strictOptions = new GameOptions { ForbidIncorrectEntries = true }; - _logger = NullLogger.Instance; + _logger = NullLogger.Instance; _stats = new GameStats(); } @@ -42,7 +42,7 @@ public async Task StartNewGame_Always_LoadsTextFromProvider() // Arrange var expectedText = "This is a test."; _mockTextProvider.SetText(expectedText); - var game = new GameEngine(_defaultOptions, _logger); + var game = new TypingSession(_defaultOptions, _logger); // Act game.LoadText(await _mockTextProvider.GetWordsAsync()); @@ -56,7 +56,7 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() { // Arrange // 1. Initial Setup - var game = new GameEngine(_defaultOptions, _logger); + var game = new TypingSession(_defaultOptions, _logger); string firstText = "some text"; // 2. Load the first game @@ -91,7 +91,7 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() { // Arrange - var game = new GameEngine(_defaultOptions, _logger); + var game = new TypingSession(_defaultOptions, _logger); game.LoadText(new TextSample() { Text = "abc", Source = "test" }); @@ -111,7 +111,7 @@ public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() { // Arrange - var game = new GameEngine(_defaultOptions, _logger); + var game = new TypingSession(_defaultOptions, _logger); game.LoadText(new TextSample() { Text = "abc", Source = "test" }); await Assert.That(game.UserInput).IsEmpty(); @@ -126,7 +126,7 @@ public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() { // Arrange - var game = new GameEngine(_defaultOptions, _logger); + var game = new TypingSession(_defaultOptions, _logger); game.LoadText(new TextSample() { Text = "hi", Source = "test" }); // Act @@ -145,7 +145,7 @@ public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() { // Arrange - var game = new GameEngine(_strictOptions, _logger); // _gameOptions.ForbidIncorrectEntries = true + var game = new TypingSession(_strictOptions, _logger); // _gameOptions.ForbidIncorrectEntries = true game.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -161,7 +161,7 @@ public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendCharacter() { // Arrange - var game = new GameEngine(_strictOptions, _logger); + var game = new TypingSession(_strictOptions, _logger); game.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -177,7 +177,7 @@ public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendChara public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter() { // Arrange - var game = new GameEngine(_defaultOptions, _logger); // _gameOptions.ForbidIncorrectEntries = false + var game = new TypingSession(_defaultOptions, _logger); // _gameOptions.ForbidIncorrectEntries = false game.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -196,7 +196,7 @@ public async Task ProcessKeyPress_WithRandomText_MatchesState() var text = lorem.Sentence(); - var sut = new GameEngine(_defaultOptions, _logger); + var sut = new TypingSession(_defaultOptions, _logger); sut.LoadText(new TextSample() { Text = text, Source = "Bogus" }); var enumerator = StringInfo.GetTextElementEnumerator(text); From e5154bbb6efe2986c6f95c6babfd8e883da6fa3a Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Tue, 19 May 2026 14:58:32 +0100 Subject: [PATCH 14/58] test: considerably more --- src/Typical.Core/Data/Quote.cs | 1 + .../Services/MessengerServiceExtensions.cs | 13 ++ .../Services/ServiceExtensions.cs | 1 + src/Typical.Core/Statistics/GameStats.cs | 4 +- src/Typical.Core/TypingSession.cs | 12 +- .../ViewModels/SettingsViewModel.cs | 7 +- .../ViewModels/TypingViewModel.cs | 13 +- .../Sqlite/TextRepository.cs | 32 ++++- src/Typical.Tests/Core/GameStatsTests.cs | 90 ------------ .../Core/Statistics/GameStatsTests.cs | 50 +++++++ .../Statistics}/KeystrokeCollectionTests.cs | 7 +- .../Statistics}/TypingSessionTests.cs | 39 ++++-- .../{ => Core/Text}/TypingBufferTests.cs | 2 +- .../Core/ViewModels/StatsViewModelTests.cs | 40 ++++++ .../Core/ViewModels/TypingViewModelTests.cs | 91 ++++++++++++ src/Typical.Tests/Data/TextRepositoryTests.cs | 58 ++++++++ src/Typical.Tests/NavigationServiceTests.cs | 91 ------------ src/Typical.Tests/StatsViewModelTests.cs | 33 ----- src/Typical.Tests/{ => UI}/BindingTests.cs | 2 +- .../UI/NavigationServiceTests.cs | 129 ++++++++++++++++++ 20 files changed, 462 insertions(+), 253 deletions(-) create mode 100644 src/Typical.Core/Services/MessengerServiceExtensions.cs delete mode 100644 src/Typical.Tests/Core/GameStatsTests.cs create mode 100644 src/Typical.Tests/Core/Statistics/GameStatsTests.cs rename src/Typical.Tests/{ => Core/Statistics}/KeystrokeCollectionTests.cs (96%) rename src/Typical.Tests/{ => Core/Statistics}/TypingSessionTests.cs (82%) rename src/Typical.Tests/{ => Core/Text}/TypingBufferTests.cs (98%) create mode 100644 src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs create mode 100644 src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs create mode 100644 src/Typical.Tests/Data/TextRepositoryTests.cs delete mode 100644 src/Typical.Tests/NavigationServiceTests.cs delete mode 100644 src/Typical.Tests/StatsViewModelTests.cs rename src/Typical.Tests/{ => UI}/BindingTests.cs (99%) create mode 100644 src/Typical.Tests/UI/NavigationServiceTests.cs diff --git a/src/Typical.Core/Data/Quote.cs b/src/Typical.Core/Data/Quote.cs index 271ee42..df93e4d 100644 --- a/src/Typical.Core/Data/Quote.cs +++ b/src/Typical.Core/Data/Quote.cs @@ -14,6 +14,7 @@ public interface ITextRepository { Task GetRandomQuoteAsync(); Task GetQuoteAsync(int currentId); + Task GetQuoteByIdAsync(int id); Task AddQuotesAsync(IEnumerable quotes); Task HasAnyAsync(); } diff --git a/src/Typical.Core/Services/MessengerServiceExtensions.cs b/src/Typical.Core/Services/MessengerServiceExtensions.cs new file mode 100644 index 0000000..a0ca060 --- /dev/null +++ b/src/Typical.Core/Services/MessengerServiceExtensions.cs @@ -0,0 +1,13 @@ +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Extensions.DependencyInjection; + +namespace Typical.Core.Services; + +public static class MessengerServiceExtensions +{ + public static IServiceCollection AddMessenger(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/Typical.Core/Services/ServiceExtensions.cs b/src/Typical.Core/Services/ServiceExtensions.cs index 3b786a2..f512ba6 100644 --- a/src/Typical.Core/Services/ServiceExtensions.cs +++ b/src/Typical.Core/Services/ServiceExtensions.cs @@ -8,6 +8,7 @@ public static class ServiceExtensions { public static void AddCoreServices(this IServiceCollection services) { + services.AddMessenger(); services.AddSingleton(TimeProvider.System); services.AddSingleton(); services.AddSingleton(GameOptions.Default); diff --git a/src/Typical.Core/Statistics/GameStats.cs b/src/Typical.Core/Statistics/GameStats.cs index 119c38e..a3c502d 100644 --- a/src/Typical.Core/Statistics/GameStats.cs +++ b/src/Typical.Core/Statistics/GameStats.cs @@ -8,9 +8,9 @@ public class GameStats private long? _startTimestamp; private long? _endTimestamp; - public GameStats(TimeProvider? timeProvider = null) + public GameStats(TimeProvider timeProvider) { - _timeProvider = timeProvider ?? TimeProvider.System; + _timeProvider = timeProvider; } public IReadOnlyList Keystrokes => _keystrokes.GetLog(); diff --git a/src/Typical.Core/TypingSession.cs b/src/Typical.Core/TypingSession.cs index a665eab..95f4c3b 100644 --- a/src/Typical.Core/TypingSession.cs +++ b/src/Typical.Core/TypingSession.cs @@ -14,16 +14,22 @@ public class TypingSession private readonly TypingBuffer _userInput = new(); private string[] _targetGraphemes = []; private readonly GameOptions _gameOptions; + private readonly TimeProvider _timeProvider; // TODO: Add HeatmapCollector private readonly ILogger _logger; // private KeystrokeType[] _charStates = []; - public TypingSession(GameOptions gameOptions, ILogger logger) + public TypingSession( + GameOptions gameOptions, + ILogger logger, + TimeProvider timeProvider + ) { _gameOptions = gameOptions; - Stats = new GameStats(); + _timeProvider = timeProvider; + Stats = new GameStats(_timeProvider); _logger = logger; } @@ -113,7 +119,7 @@ public void LoadText(TextSample sample) // Array.Fill(_charStates, KeystrokeType.Untyped); IsOver = false; - Stats = new GameStats(); + Stats = new GameStats(_timeProvider); } internal KeystrokeType GetStatus(int index) diff --git a/src/Typical.Core/ViewModels/SettingsViewModel.cs b/src/Typical.Core/ViewModels/SettingsViewModel.cs index 756f4a3..9fa0435 100644 --- a/src/Typical.Core/ViewModels/SettingsViewModel.cs +++ b/src/Typical.Core/ViewModels/SettingsViewModel.cs @@ -12,6 +12,7 @@ public sealed partial class SettingsViewModel : ObservableObject private readonly IDialogService _dialogService; private readonly INavigationService _navService; private readonly ILogger _logger; + private readonly IMessenger _messenger; [ObservableProperty] private bool _enableLogging = true; @@ -19,19 +20,21 @@ public sealed partial class SettingsViewModel : ObservableObject public SettingsViewModel( IDialogService dialogService, INavigationService navService, - ILogger logger + ILogger logger, + IMessenger messenger ) { _dialogService = dialogService; _navService = navService; _logger = logger; + _messenger = messenger; } [RelayCommand] private void QuoteMode() { var message = new GameResetMessage(new QuoteMode(QuoteLength.Medium)); - WeakReferenceMessenger.Default.Send(message); + _messenger.Send(message); } [RelayCommand] diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index 8c540cf..77cd102 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -18,6 +18,7 @@ public partial class TypingViewModel private readonly ITextProvider _textProvider; private readonly INavigationService _navigationService; private readonly ILogger _logger; + private readonly IMessenger _messenger; [ObservableProperty] public required partial TextSample Target { get; set; } = TextSample.Empty; @@ -33,18 +34,17 @@ public TypingViewModel( TypingSession engine, ITextProvider textProvider, INavigationService navigationService, - ILogger logger + ILogger logger, + IMessenger messenger ) { _engine = engine; _textProvider = textProvider; _navigationService = navigationService; _logger = logger; + _messenger = messenger; - WeakReferenceMessenger.Default.Register( - this, - (r, m) => r.Receive(m) - ); + _messenger.Register(this, (r, m) => r.Receive(m)); } public bool IsGameOver => _engine.IsOver; @@ -76,8 +76,7 @@ public async void ProcessInput(string c, bool isBackspace) private void UpdateState() { var snapshot = _engine.CreateSnapshot(); - - WeakReferenceMessenger.Default.Send(new GameStatsUpdatedMessage(snapshot)); + _messenger.Send(new GameStatsUpdatedMessage(snapshot)); } public void OnNavigatedTo() diff --git a/src/Typical.DataAccess/Sqlite/TextRepository.cs b/src/Typical.DataAccess/Sqlite/TextRepository.cs index d8b03d2..41ba55e 100644 --- a/src/Typical.DataAccess/Sqlite/TextRepository.cs +++ b/src/Typical.DataAccess/Sqlite/TextRepository.cs @@ -12,6 +12,21 @@ namespace Typical.DataAccess.Sqlite; public class TextRepository(IOptions options) : ITextRepository { + public async Task GetQuoteByIdAsync(int id) + { + await using var connection = await GetOpenConnectionAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = + @"SELECT Id, Text, Author, Tags, WordCount, CharCount FROM Quotes WHERE Id = @id;"; + command.Parameters.AddWithValue("@id", id); + await using var reader = await command.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + return MapReaderToQuote(reader); + } + throw new InvalidOperationException($"No quote found with Id {id}."); + } + private async Task GetOpenConnectionAsync() { var connection = new SqliteConnection(options.Value.GetConnectionString()); @@ -37,22 +52,27 @@ WHERE Id > @id ORDER BY Id ASC LIMIT 1;"; command.Parameters.AddWithValue("@id", id); - await using var reader = await command.ExecuteReaderAsync(); - if (await reader.ReadAsync()) + await using (var reader = await command.ExecuteReaderAsync()) { - return MapReaderToQuote(reader); + if (await reader.ReadAsync()) + { + return MapReaderToQuote(reader); + } } + // Now safe to reuse command command.CommandText = @" SELECT Id, Text, Author, Tags, WordCount, CharCount FROM Quotes ORDER BY Id ASC LIMIT 1;"; - await using var wrapReader = await command.ExecuteReaderAsync(); - if (await wrapReader.ReadAsync()) + await using (var wrapReader = await command.ExecuteReaderAsync()) { - return MapReaderToQuote(wrapReader); + if (await wrapReader.ReadAsync()) + { + return MapReaderToQuote(wrapReader); + } } throw new InvalidOperationException( diff --git a/src/Typical.Tests/Core/GameStatsTests.cs b/src/Typical.Tests/Core/GameStatsTests.cs deleted file mode 100644 index 716e369..0000000 --- a/src/Typical.Tests/Core/GameStatsTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -// using System; -// using Microsoft.Extensions.Logging.Abstractions; -// using Microsoft.Extensions.Time.Testing; -// using TUnit; -// using Typical.Core.Events; -// using Typical.Core.Statistics; - -// namespace Typical.Tests -// { -// public class GameStatsTests -// { -// [Test] -// public async Task InitialState_ShouldBeDefaults() -// { -// var eventAggregator = new EventAggregator(); -// var stats = new GameStats(eventAggregator, null, NullLogger.Instance); - -// await Assert.That(stats.WordsPerMinute).IsEqualTo(0); -// await Assert.That(stats.Accuracy).IsEqualTo(100); -// await Assert.That(stats.IsRunning).IsFalse(); -// } - -// [Test] -// public async Task Start_ShouldSetIsRunningTrue() -// { -// var fakeTime = new FakeTimeProvider(); -// var eventAggregator = new EventAggregator(); -// var stats = new GameStats(eventAggregator, fakeTime, NullLogger.Instance); - -// stats.Start(); - -// await Assert.That(stats.IsRunning).IsTrue(); -// } - -// [Test] -// public async Task Stop_ShouldSetIsRunningFalse() -// { -// var fakeTime = new FakeTimeProvider(); -// var eventAggregator = new EventAggregator(); -// var stats = new GameStats(eventAggregator, fakeTime, NullLogger.Instance); - -// stats.Start(); -// fakeTime.Advance(TimeSpan.FromSeconds(1)); -// stats.Stop(); - -// await Assert.That(stats.IsRunning).IsFalse(); -// } - -// [Test] -// public async Task Update_ShouldCalculateAccuracy() -// { -// var fakeTime = new FakeTimeProvider(); -// var eventAggregator = new EventAggregator(); -// var stats = new GameStats(eventAggregator, fakeTime, NullLogger.Instance); - -// stats.Start(); -// fakeTime.Advance(TimeSpan.FromSeconds(1)); -// string target = "hello"; -// string input = "hxllo"; // 1 incorrect out of 5 - -// foreach (var (c, i) in target.Zip(input)) -// { -// var type = c == i ? KeystrokeType.Correct : KeystrokeType.Incorrect; -// eventAggregator.Publish(new KeyPressedEvent(i, type, 0)); -// } -// await Assert.That(stats.Accuracy).IsEqualTo(80); -// } - -// [Test] -// public async Task Update_ShouldCalculateWordsPerMinute() -// { -// var fakeTime = new FakeTimeProvider(); -// var eventAggregator = new EventAggregator(); -// var stats = new GameStats(eventAggregator, fakeTime, NullLogger.Instance); - -// stats.Start(); -// fakeTime.Advance(TimeSpan.FromSeconds(1)); -// string target = "hello world"; -// string input = "hello"; - -// foreach (var (c, i) in target.Zip(input)) -// { -// var type = c == i ? KeystrokeType.Correct : KeystrokeType.Incorrect; -// eventAggregator.Publish(new KeyPressedEvent(i, type, 0)); -// } - -// await Assert.That(stats.WordsPerMinute).IsEqualTo(60); -// } -// } -// } diff --git a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs new file mode 100644 index 0000000..e3327ce --- /dev/null +++ b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Time.Testing; +using Typical.Core.Statistics; + +namespace Typical.Tests.Core.Statistics; + +public class GameStatsTests +{ + [Test] + public async Task CreateSnapshot_CalculatesAccurateWPM_BasedOnTime() + { + var fakeTime = new FakeTimeProvider(); + var stats = new GameStats(fakeTime); + stats.Start(); + // Record 6 correct characters ("hello ") + for (int i = 0; i < 6; i++) + stats.RecordKey("a", KeystrokeType.Correct); + fakeTime.Advance(TimeSpan.FromSeconds(12)); + stats.Stop(); + var snapshot = stats.CreateSnapshot(); + await Assert.That(snapshot.WPM.Value).IsEqualTo(6).Within(0.0001); + await Assert.That(snapshot.Accuracy.Value).IsEqualTo(100); + } + + [Test] + public async Task CreateSnapshot_CalculatesAccuracy_WithErrors() + { + var fakeTime = new FakeTimeProvider(); + var stats = new GameStats(fakeTime); + stats.Start(); + for (int i = 0; i < 9; i++) + stats.RecordKey("a", KeystrokeType.Correct); + stats.RecordKey("b", KeystrokeType.Incorrect); + fakeTime.Advance(TimeSpan.FromSeconds(10)); + stats.Stop(); + var snapshot = stats.CreateSnapshot(); + await Assert.That(snapshot.Accuracy.Value).IsEqualTo(90); + } + + [Test] + public async Task CreateSnapshot_HandlesZeroElapsed_PreventsDivideByZero() + { + var fakeTime = new FakeTimeProvider(); + var stats = new GameStats(fakeTime); + stats.Start(); + stats.Stop(); + var snapshot = stats.CreateSnapshot(); + await Assert.That(snapshot.WPM.Value).IsEqualTo(0); + await Assert.That(snapshot.Accuracy.Value).IsEqualTo(100); + } +} diff --git a/src/Typical.Tests/KeystrokeCollectionTests.cs b/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs similarity index 96% rename from src/Typical.Tests/KeystrokeCollectionTests.cs rename to src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs index 56e8ed0..4684817 100644 --- a/src/Typical.Tests/KeystrokeCollectionTests.cs +++ b/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs @@ -1,11 +1,6 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using TUnit; -using TUnit; using Typical.Core.Statistics; -namespace Typical.Tests; +namespace Typical.Tests.Core.Statistics; public class KeystrokeCollectionTests { diff --git a/src/Typical.Tests/TypingSessionTests.cs b/src/Typical.Tests/Core/Statistics/TypingSessionTests.cs similarity index 82% rename from src/Typical.Tests/TypingSessionTests.cs rename to src/Typical.Tests/Core/Statistics/TypingSessionTests.cs index 52941e1..858baa3 100644 --- a/src/Typical.Tests/TypingSessionTests.cs +++ b/src/Typical.Tests/Core/Statistics/TypingSessionTests.cs @@ -9,7 +9,7 @@ using Typical.Core.Statistics; using Typical.Core.Text; -namespace Typical.Tests; +namespace Typical.Tests.Core.Statistics; public class TypingSessionTests { @@ -31,7 +31,7 @@ public TypingSessionTests() _defaultOptions = new GameOptions(); _strictOptions = new GameOptions { ForbidIncorrectEntries = true }; _logger = NullLogger.Instance; - _stats = new GameStats(); + _stats = new GameStats(TimeProvider.System); } // --- StartNewGame Tests --- @@ -42,7 +42,7 @@ public async Task StartNewGame_Always_LoadsTextFromProvider() // Arrange var expectedText = "This is a test."; _mockTextProvider.SetText(expectedText); - var game = new TypingSession(_defaultOptions, _logger); + var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); // Act game.LoadText(await _mockTextProvider.GetWordsAsync()); @@ -56,7 +56,7 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() { // Arrange // 1. Initial Setup - var game = new TypingSession(_defaultOptions, _logger); + var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); string firstText = "some text"; // 2. Load the first game @@ -91,7 +91,7 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() { // Arrange - var game = new TypingSession(_defaultOptions, _logger); + var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); game.LoadText(new TextSample() { Text = "abc", Source = "test" }); @@ -111,7 +111,7 @@ public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() { // Arrange - var game = new TypingSession(_defaultOptions, _logger); + var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); game.LoadText(new TextSample() { Text = "abc", Source = "test" }); await Assert.That(game.UserInput).IsEmpty(); @@ -126,7 +126,7 @@ public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() { // Arrange - var game = new TypingSession(_defaultOptions, _logger); + var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); game.LoadText(new TextSample() { Text = "hi", Source = "test" }); // Act @@ -145,7 +145,7 @@ public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() { // Arrange - var game = new TypingSession(_strictOptions, _logger); // _gameOptions.ForbidIncorrectEntries = true + var game = new TypingSession(_strictOptions, _logger, TimeProvider.System); // _gameOptions.ForbidIncorrectEntries = true game.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -161,7 +161,7 @@ public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendCharacter() { // Arrange - var game = new TypingSession(_strictOptions, _logger); + var game = new TypingSession(_strictOptions, _logger, TimeProvider.System); game.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -177,7 +177,7 @@ public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendChara public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter() { // Arrange - var game = new TypingSession(_defaultOptions, _logger); // _gameOptions.ForbidIncorrectEntries = false + var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); // _gameOptions.ForbidIncorrectEntries = false game.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -196,7 +196,7 @@ public async Task ProcessKeyPress_WithRandomText_MatchesState() var text = lorem.Sentence(); - var sut = new TypingSession(_defaultOptions, _logger); + var sut = new TypingSession(_defaultOptions, _logger, TimeProvider.System); sut.LoadText(new TextSample() { Text = text, Source = "Bogus" }); var enumerator = StringInfo.GetTextElementEnumerator(text); @@ -211,6 +211,23 @@ public async Task ProcessKeyPress_WithRandomText_MatchesState() await Assert.That(sut.IsOver).IsTrue(); } + [Test] + public async Task ProcessKeyPress_WithEmoji_HandlesGraphemesCorrectly() + { + // Arrange: emoji with modifier (👍🏽) + var emojiText = "👍🏽"; + var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); + game.LoadText(new TextSample { Text = emojiText, Source = "test" }); + + // Act: process the emoji as a single grapheme + game.ProcessKeyPress("👍🏽", false); + + // Assert: should count as exactly 1 correct keystroke + await Assert.That(game.Stats.Keystrokes.Count).IsEqualTo(1); + await Assert.That(game.Stats.Keystrokes[0].Grapheme).IsEqualTo("👍🏽"); + await Assert.That(game.Stats.Keystrokes[0].Type).IsEqualTo(KeystrokeType.Correct); + } + // [Test] // [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] // public async Task Engine_ShouldHandleWordsInInternationalLocales(string locale) diff --git a/src/Typical.Tests/TypingBufferTests.cs b/src/Typical.Tests/Core/Text/TypingBufferTests.cs similarity index 98% rename from src/Typical.Tests/TypingBufferTests.cs rename to src/Typical.Tests/Core/Text/TypingBufferTests.cs index dc63ef9..06c6f11 100644 --- a/src/Typical.Tests/TypingBufferTests.cs +++ b/src/Typical.Tests/Core/Text/TypingBufferTests.cs @@ -2,7 +2,7 @@ using TUnit; using Typical.Core.Text; -namespace Typical.Tests; +namespace Typical.Tests.Core.Text; public class TypingBufferTests { diff --git a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs new file mode 100644 index 0000000..db31013 --- /dev/null +++ b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs @@ -0,0 +1,40 @@ +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Typical.Core.Events; +using Typical.Core.Statistics; +using Typical.Core.ViewModels; + +namespace Typical.Tests.Core.ViewModels; + +public class StatsViewModelTests +{ + [Test] + public async Task Receive_GameStatsUpdatedMessage_UpdatesProperties() + { + var services = new ServiceCollection(); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + var messenger = provider.GetRequiredService(); + var sut = new StatsViewModel(); + + // Manually register with the test messenger + messenger.Register(sut, (r, m) => r.Receive(m)); + + var fakeSnapshot = new GameStatsSnapshot( + WPM: (WPM)65.8, + Accuracy: (Accuracy)98.5, + Chars: new CharacterStats(10, 1, 2), + ElapsedTime: TimeSpan.FromSeconds(30) + ); + var msg = new GameStatsUpdatedMessage(fakeSnapshot); + + messenger.Send(msg); + + await Assert.That(sut.Stats.WPM.Value).IsEqualTo(65.8); + await Assert.That(sut.Stats.Accuracy.Value).IsEqualTo(98.5); + await Assert.That(sut.Stats.Chars.Correct).IsEqualTo(10); + await Assert.That(sut.Stats.Chars.Incorrect).IsEqualTo(1); + await Assert.That(sut.Stats.Chars.Corrections).IsEqualTo(2); + await Assert.That(sut.Stats.ElapsedTime).IsEqualTo(TimeSpan.FromSeconds(30)); + } +} diff --git a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs new file mode 100644 index 0000000..0d84895 --- /dev/null +++ b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs @@ -0,0 +1,91 @@ +using CommunityToolkit.Mvvm.Messaging; +using Imposter.Abstractions; +using Microsoft.Extensions.Logging.Abstractions; +using Typical.Core; +using Typical.Core.Events; +using Typical.Core.Interfaces; +using Typical.Core.Text; +using Typical.Core.ViewModels; + +[assembly: GenerateImposter(typeof(IMessenger))] +[assembly: GenerateImposter(typeof(INavigationService))] + +namespace Typical.Tests.Core.ViewModels; + +public class TypingViewModelTests +{ + [Test] + public async Task InitializeAsync_LoadsQuote_FromTextProvider() + { + var mockTextProvider = new MockTextProvider(); + mockTextProvider.SetText("Hello world!"); + var engine = new TypingSession( + GameOptions.Default, + NullLogger.Instance, + TimeProvider.System + ); + var mockMessenger = IMessenger.Imposter(); + var mockNavigationService = INavigationService.Imposter(); + var vm = new TypingViewModel( + engine, + mockTextProvider, + mockNavigationService.Instance(), + NullLogger.Instance, + mockMessenger.Instance() + ); + await vm.InitializeAsync(); + await Assert.That(vm.Target.Text).IsEqualTo("Hello world!"); + } + + [Test] + public async Task ProcessInput_UpdatesEngine_AndBroadcastsMessage() + { + var mockTextProvider = new MockTextProvider(); + mockTextProvider.SetText("a"); + var engine = new TypingSession( + GameOptions.Default, + NullLogger.Instance, + TimeProvider.System + ); + var mockMessenger = IMessenger.Imposter(); + + var messenger = mockMessenger.Instance(); + var mockNavigationService = INavigationService.Imposter(); + var vm = new TypingViewModel( + engine, + mockTextProvider, + mockNavigationService.Instance(), + NullLogger.Instance, + mockMessenger.Instance() + ); + await vm.InitializeAsync(); + vm.ProcessInput("a", false); + // Assert observable state: engine should have processed the input + await Assert.That(engine.UserInput).IsEqualTo("a"); + } + + [Test] + public async Task Receive_GameResetMessage_ReloadsText_BasedOnSettings() + { + var mockTextProvider = new MockTextProvider(); + mockTextProvider.SetText("reset quote"); + var engine = new TypingSession( + GameOptions.Default, + NullLogger.Instance, + TimeProvider.System + ); + var mockMessenger = IMessenger.Imposter(); + var mockNavigationService = INavigationService.Imposter(); + var vm = new TypingViewModel( + engine, + mockTextProvider, + mockNavigationService.Instance(), + NullLogger.Instance, + mockMessenger.Instance() + ); + var msg = new GameResetMessage(new QuoteMode(QuoteLength.Short)); + vm.Receive(msg); + await Task.Delay(10); // Allow async to complete + await Assert.That(vm.Target.Text).IsEqualTo("reset quote"); + } +} diff --git a/src/Typical.Tests/Data/TextRepositoryTests.cs b/src/Typical.Tests/Data/TextRepositoryTests.cs new file mode 100644 index 0000000..bef3ba1 --- /dev/null +++ b/src/Typical.Tests/Data/TextRepositoryTests.cs @@ -0,0 +1,58 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Typical.DataAccess; +using Typical.DataAccess.Sqlite; + +namespace Typical.Tests.Data; + +public class TextRepositoryTests +{ + private static async Task<(SqliteConnection, TextRepository)> CreateInMemoryRepoAsync() + { + // Use a unique in-memory database name per test run to avoid schema conflicts + var dbName = $"memdb_{Guid.NewGuid()}"; + var connectionString = $"Data Source=file:{dbName}?mode=memory&cache=shared"; + var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(); + var options = new TypicalDbOptions + { + DataDirectory = "", + DatabaseFileName = $"file:{dbName}?mode=memory&cache=shared", + }; + var optionsWrapper = new Microsoft.Extensions.Options.OptionsWrapper( + options + ); + var migrator = new DatabaseMigrator(optionsWrapper, NullLogger.Instance); + await migrator.EnsureDatabaseUpdated(); + var repo = new TextRepository(optionsWrapper); + return (connection, repo); + } + + [Test] + public async Task GetRandomQuoteAsync_ReturnsSeededQuote() + { + var (conn, repo) = await CreateInMemoryRepoAsync(); + var quote = await repo.GetRandomQuoteAsync(); + await Assert.That(quote).IsNotNull(); + await Assert.That(quote.Text).IsNotEmpty(); + await Assert.That(quote.Author).IsNotEmpty(); + await conn.DisposeAsync(); + } + + [Test] + public async Task GetQuoteAsync_MapsDBNullAuthor_ToUnknown() + { + var (conn, repo) = await CreateInMemoryRepoAsync(); + // Insert a quote with NULL author + var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO Quotes (Text, Author) VALUES (@text, NULL);"; + cmd.Parameters.AddWithValue("@text", "Anonymous wisdom"); + await cmd.ExecuteNonQueryAsync(); + // Get the last inserted row + cmd.CommandText = "SELECT last_insert_rowid();"; + var id = (long)await cmd.ExecuteScalarAsync(); + var quote = await repo.GetQuoteByIdAsync((int)id); + await Assert.That(quote.Author).IsEqualTo("Unknown"); + await conn.DisposeAsync(); + } +} diff --git a/src/Typical.Tests/NavigationServiceTests.cs b/src/Typical.Tests/NavigationServiceTests.cs deleted file mode 100644 index aa3fa91..0000000 --- a/src/Typical.Tests/NavigationServiceTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Messaging; -using Imposter.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Typical.Core; -using Typical.Core.Events; -using Typical.Core.Interfaces; -using Typical.Core.ViewModels; -using Typical.Services; - -[assembly: GenerateImposter(typeof(TypingSession))] - -namespace Typical.Tests; - -public class NavigationServiceTests -{ - private ServiceProvider _serviceProvider = null!; - private NavigationService _navigationService = null!; - private IMessenger _messenger = null!; - - [Before(Test)] - public void Setup() - { - var services = new ServiceCollection(); - _messenger = new StrongReferenceMessenger(); - - // Register mock ViewModels - services.AddTransient(sp => new HomeViewModel( - _navigationService, - NullLogger.Instance - )); - var gameEngine = new TypingSession(GameOptions.Default, NullLogger.Instance); - services.AddTransient(sp => new TypingViewModel( - gameEngine, - new MockTextProvider(), - _navigationService, - NullLogger.Instance - )); - - services.AddSingleton(sp => _navigationService); - - _serviceProvider = services.BuildServiceProvider(); - - _navigationService = new NavigationService(_serviceProvider, null!, _messenger); - } - - [After(Test)] - public void CleanUp() - { - _messenger.UnregisterAll(this); - } - - [Test] - public async Task NavigateTo_ShouldBroadcastNavigationChangedMessage() - { - // Arrange - NavigationChangedMessage? receivedMessage = null; - _messenger.Register( - this, - (r, m) => - { - receivedMessage = m; - } - ); - - try - { - // Act - _navigationService.NavigateTo(); - - // Assert - await Assert.That(receivedMessage).IsNotNull(); - await Assert.That(receivedMessage!.Value).IsTypeOf(); - } - finally - { - _messenger.UnregisterAll(this); - } - } - - [Test] - public async Task NavigateTo_ShouldUpdateCurrentViewModel() - { - // Act - _navigationService.NavigateTo(); - - // Assert - await Assert.That(_navigationService.CurrentViewModel).IsTypeOf(); - } -} diff --git a/src/Typical.Tests/StatsViewModelTests.cs b/src/Typical.Tests/StatsViewModelTests.cs deleted file mode 100644 index 099fa1b..0000000 --- a/src/Typical.Tests/StatsViewModelTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CommunityToolkit.Mvvm.Messaging; -using Typical.Core.Events; -using Typical.Core.Statistics; -using Typical.Core.ViewModels; - -namespace Typical.Tests; - -public class StatsViewModelTests -{ - // [Test] - // public async Task Receive_GamesStateUpdatedEvent_UpdatesViewModelCorrectly() - // { - // var messenger = WeakReferenceMessenger.Default; - - // var sut = new StatsViewModel(); - - // var fakeState = new GameSnapshot( - // WPM: (WPM)65.8, - // Accuracy: (Accuracy)98.5, - // Chars: new CharacterStats(0, 0, 0), - // ElapsedTime: TimeSpan.FromSeconds(30), - // TargetText: "Test", - // UserInput: "Test" - // ); - - // var gameEvent = new GameStateUpdatedMessage(State: fakeState); - - // messenger.Send(gameEvent); - - // await Assert.That(sut.Stats.WPM).IsEqualTo(65.8); - // await Assert.That(sut.Stats.Accuracy).IsEqualTo((Accuracy)98.5); - // } -} diff --git a/src/Typical.Tests/BindingTests.cs b/src/Typical.Tests/UI/BindingTests.cs similarity index 99% rename from src/Typical.Tests/BindingTests.cs rename to src/Typical.Tests/UI/BindingTests.cs index 7b404e0..77a16c0 100644 --- a/src/Typical.Tests/BindingTests.cs +++ b/src/Typical.Tests/UI/BindingTests.cs @@ -4,7 +4,7 @@ using Terminal.Gui.Views; using Typical.Binding; -namespace Typical.Tests; +namespace Typical.Tests.UI; public partial class BindingTests { diff --git a/src/Typical.Tests/UI/NavigationServiceTests.cs b/src/Typical.Tests/UI/NavigationServiceTests.cs new file mode 100644 index 0000000..2229b0c --- /dev/null +++ b/src/Typical.Tests/UI/NavigationServiceTests.cs @@ -0,0 +1,129 @@ +using CommunityToolkit.Mvvm.Messaging; +using Imposter.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Terminal.Gui.ViewBase; +using Typical.Core; +using Typical.Core.Events; +using Typical.Core.Interfaces; +using Typical.Core.Text; +using Typical.Core.ViewModels; +using Typical.Services; + +[assembly: GenerateImposter(typeof(IDialogService))] +[assembly: GenerateImposter(typeof(ITextProvider))] + +namespace Typical.Tests.UI; + +public class NavigationServiceTests +{ + private ServiceProvider _serviceProvider = null!; + private INavigationService _navigationService = null!; + private IMessenger _messenger = null!; + + [Before(Test)] + public void Setup() + { + var services = new ServiceCollection(); + + _messenger = new StrongReferenceMessenger(); + services.AddSingleton(_messenger); + + var dialogServiceMock = IDialogService.Imposter(); + var textProviderMock = ITextProvider.Imposter(); + + services.AddSingleton(dialogServiceMock.Instance()); + services.AddSingleton(textProviderMock.Instance()); + + services.AddSingleton(sp => new TypingSession( + new GameOptions(), + NullLogger.Instance, + TimeProvider.System + )); + + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton>(NullLogger.Instance); + + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddSingleton(sp => new NavigationService( + sp, + null!, + sp.GetRequiredService() + )); + + _serviceProvider = services.BuildServiceProvider(); + + _navigationService = (NavigationService) + _serviceProvider.GetRequiredService(); + } + + [After(Test)] + public void CleanUp() + { + _messenger.UnregisterAll(this); + } + + [Test] + public async Task NavigateTo_ShouldBroadcastNavigationChangedMessage() + { + // Arrange + NavigationChangedMessage? receivedMessage = null; + _messenger.Register(this, (r, m) => receivedMessage = m); + + try + { + // Act + _navigationService.NavigateTo(); + + // Assert + await Assert.That(receivedMessage).IsNotNull(); + await Assert.That(receivedMessage!.Value).IsTypeOf(); + } + finally + { + _messenger.UnregisterAll(this); + } + } + + [Test] + public async Task NavigateTo_ShouldUpdateCurrentViewModel() + { + // Act + _navigationService.NavigateTo(); + + // Assert + await Assert.That(_navigationService.CurrentViewModel).IsTypeOf(); + } + + [Test] + public async Task ViewLocator_MapsAllNavigatableViewModels() + { + var coreAssembly = typeof(HomeViewModel).Assembly; + + var navigatableTypes = coreAssembly + .GetTypes() + .Where(t => typeof(INavigatableView).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract) + .ToList(); + + foreach (var type in navigatableTypes) + { + var viewModel = _serviceProvider.GetRequiredService(type); + + // Act + var view = Typical.Navigation.ViewLocator.GetView(_serviceProvider, viewModel); + + // Assert + await Assert.That(view).IsNotNull(); + await Assert.That(view).IsAssignableTo(); + } + } +} From 3dc7413b13a98c6e1e0fd45aef29671b0c74e597 Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Tue, 19 May 2026 15:43:27 +0100 Subject: [PATCH 15/58] update build config --- Directory.Build.props | 6 ------ Typical.slnx | 4 ++-- global.json | 2 +- src/Directory.Build.props | 1 + Directory.Packages.props => src/Directory.Packages.props | 0 5 files changed, 4 insertions(+), 9 deletions(-) delete mode 100644 Directory.Build.props rename Directory.Packages.props => src/Directory.Packages.props (100%) diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index a3210be..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,6 +0,0 @@ - - - - $(MSBuildThisFileDirectory).artifacts - - diff --git a/Typical.slnx b/Typical.slnx index c241575..b505f44 100644 --- a/Typical.slnx +++ b/Typical.slnx @@ -2,8 +2,8 @@ - - + + diff --git a/global.json b/global.json index 94bfd02..9e41469 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "11.0.100-preview.3.26207.106", + "version": "11.0.100-preview.4.26230.115", "allowPrerelease": true }, "test": { diff --git a/src/Directory.Build.props b/src/Directory.Build.props index dea7cf0..8ce16d2 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -7,6 +7,7 @@ enable embedded $(MSBuildThisFileDirectory)..\.artifacts + true diff --git a/Directory.Packages.props b/src/Directory.Packages.props similarity index 100% rename from Directory.Packages.props rename to src/Directory.Packages.props From a88c68e65ef86f3e4abc7d7e4061f132435880a4 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 20 May 2026 02:34:51 +0100 Subject: [PATCH 16/58] test: fixes --- .../Core/Statistics/TypingSessionTests.cs | 197 ++++++++++-------- 1 file changed, 105 insertions(+), 92 deletions(-) diff --git a/src/Typical.Tests/Core/Statistics/TypingSessionTests.cs b/src/Typical.Tests/Core/Statistics/TypingSessionTests.cs index 858baa3..6cac10f 100644 --- a/src/Typical.Tests/Core/Statistics/TypingSessionTests.cs +++ b/src/Typical.Tests/Core/Statistics/TypingSessionTests.cs @@ -3,6 +3,7 @@ using Bogus; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; using TUnit.Core.Logging; using Typical.Core; using Typical.Core.Events; @@ -13,6 +14,7 @@ namespace Typical.Tests.Core.Statistics; public class TypingSessionTests { + private readonly TimeProvider _timeProvider = new FakeTimeProvider(new DateTime(2025, 01, 01)); private readonly MockTextProvider _mockTextProvider; private readonly GameOptions _defaultOptions; private readonly GameOptions _strictOptions; @@ -31,7 +33,7 @@ public TypingSessionTests() _defaultOptions = new GameOptions(); _strictOptions = new GameOptions { ForbidIncorrectEntries = true }; _logger = NullLogger.Instance; - _stats = new GameStats(TimeProvider.System); + _stats = new GameStats(_timeProvider); } // --- StartNewGame Tests --- @@ -42,13 +44,13 @@ public async Task StartNewGame_Always_LoadsTextFromProvider() // Arrange var expectedText = "This is a test."; _mockTextProvider.SetText(expectedText); - var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); + var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); // Act - game.LoadText(await _mockTextProvider.GetWordsAsync()); + sut.LoadText(await _mockTextProvider.GetWordsAsync()); // Assert - await Assert.That(game.TargetText).IsEqualTo(expectedText); + await Assert.That(sut.TargetText).IsEqualTo(expectedText); } [Test] @@ -56,33 +58,30 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() { // Arrange // 1. Initial Setup - var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); + var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); string firstText = "some text"; - // 2. Load the first game - game.LoadText(new TextSample() { Text = firstText, Source = "test" }); + // 2. Load the first sut + sut.LoadText(new TextSample() { Text = firstText, Source = "test" }); - // 3. Simulate playing the game - game.ProcessKeyPress("s", false); // Correct first char - game.ProcessKeyPress("o", false); + // 3. Simulate playing the sut + sut.ProcessKeyPress("s", false); // Correct first char + sut.ProcessKeyPress("o", false); // Check that we actually have progress - await Assert.That(game.UserInput).IsEqualTo("so"); - await Assert.That(game.IsRunning).IsTrue(); + await Assert.That(sut.UserInput).IsEqualTo("so"); + await Assert.That(sut.IsRunning).IsTrue(); - // 4. Simulate an "Abort" via the Engine's Reset/Load mechanism - // (Esc is handled by the ViewModel, which then calls the Engine to reset) string newText = "new text"; // Act - Loading new text should completely reset the internal state - game.LoadText(new TextSample() { Text = newText, Source = "test" }); + sut.LoadText(new TextSample() { Text = newText, Source = "test" }); // Assert - await Assert.That(game.IsOver).IsFalse(); - await Assert.That(game.IsRunning).IsFalse(); // Should not be running until first key - await Assert.That(game.UserInput).IsEmpty(); - await Assert.That(game.TargetText).IsEqualTo("new text"); - // await Assert.That(game.CharacterStates.All(s => s == KeystrokeType.Untyped)).IsTrue(); + await Assert.That(sut.IsOver).IsFalse(); + await Assert.That(sut.IsRunning).IsFalse(); // Should not be running until first key + await Assert.That(sut.UserInput).IsEmpty(); + await Assert.That(sut.TargetText).IsEqualTo("new text"); } // --- ProcessKeyPress Tests --- @@ -91,52 +90,51 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() { // Arrange - var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); + var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); - game.LoadText(new TextSample() { Text = "abc", Source = "test" }); + sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); - game.ProcessKeyPress("a", false); - game.ProcessKeyPress("b", false); - await Assert.That(game.UserInput).IsEqualTo("ab"); + sut.ProcessKeyPress("a", false); + sut.ProcessKeyPress("b", false); + await Assert.That(sut.UserInput).IsEqualTo("ab"); - // Act - Pass '\0' or any char with isBackspace = true - game.ProcessKeyPress("\0", true); + // Act + sut.ProcessKeyPress("\0", true); // Assert - await Assert.That(game.UserInput).IsEqualTo("a"); - // await Assert.That(game.CharacterStates[1]).IsEqualTo(KeystrokeType.Untyped); + await Assert.That(sut.UserInput).IsEqualTo("a"); } [Test] public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() { // Arrange - var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); - game.LoadText(new TextSample() { Text = "abc", Source = "test" }); - await Assert.That(game.UserInput).IsEmpty(); + var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); + await Assert.That(sut.UserInput).IsEmpty(); // Act - game.ProcessKeyPress("\0", true); + sut.ProcessKeyPress("\0", true); // Assert - await Assert.That(game.UserInput).IsEmpty(); + await Assert.That(sut.UserInput).IsEmpty(); } [Test] public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() { // Arrange - var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); - game.LoadText(new TextSample() { Text = "hi", Source = "test" }); + var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + sut.LoadText(new TextSample() { Text = "hi", Source = "test" }); // Act - game.ProcessKeyPress("h", false); - game.ProcessKeyPress("i", false); + sut.ProcessKeyPress("h", false); + sut.ProcessKeyPress("i", false); // Assert - await Assert.That(game.UserInput).IsEqualTo("hi"); - await Assert.That(game.IsOver).IsTrue(); - await Assert.That(game.IsRunning).IsFalse(); + await Assert.That(sut.UserInput).IsEqualTo("hi"); + await Assert.That(sut.IsOver).IsTrue(); + await Assert.That(sut.IsRunning).IsFalse(); } // --- GameOptions: ForbidIncorrectEntries (Strict Mode) Tests --- @@ -145,48 +143,45 @@ public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() { // Arrange - var game = new TypingSession(_strictOptions, _logger, TimeProvider.System); // _gameOptions.ForbidIncorrectEntries = true - game.LoadText(new TextSample() { Text = "abc", Source = "test" }); + var sut = new TypingSession(_strictOptions, _logger, _timeProvider); // _gameOptions.ForbidIncorrectEntries = true + sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act - bool result = game.ProcessKeyPress("a", false); + bool result = sut.ProcessKeyPress("a", false); // Assert await Assert.That(result).IsTrue(); - await Assert.That(game.UserInput).IsEqualTo("a"); - // await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Correct); + await Assert.That(sut.UserInput).IsEqualTo("a"); } [Test] public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendCharacter() { // Arrange - var game = new TypingSession(_strictOptions, _logger, TimeProvider.System); - game.LoadText(new TextSample() { Text = "abc", Source = "test" }); + var sut = new TypingSession(_strictOptions, _logger, _timeProvider); + sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act - bool result = game.ProcessKeyPress("x", false); + bool result = sut.ProcessKeyPress("x", false); // Assert await Assert.That(result).IsTrue(); // Engine rejected the key - await Assert.That(game.UserInput).IsEmpty(); - // await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Untyped); + await Assert.That(sut.UserInput).IsEmpty(); } [Test] public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter() { // Arrange - var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); // _gameOptions.ForbidIncorrectEntries = false - game.LoadText(new TextSample() { Text = "abc", Source = "test" }); + var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); // _gameOptions.ForbidIncorrectEntries = false + sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act - bool result = game.ProcessKeyPress("x", false); + bool result = sut.ProcessKeyPress("x", false); // Assert await Assert.That(result).IsTrue(); // Engine accepted the mistake - await Assert.That(game.UserInput).IsEqualTo("x"); - // await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Incorrect); + await Assert.That(sut.UserInput).IsEqualTo("x"); } [Test] @@ -196,7 +191,7 @@ public async Task ProcessKeyPress_WithRandomText_MatchesState() var text = lorem.Sentence(); - var sut = new TypingSession(_defaultOptions, _logger, TimeProvider.System); + var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); sut.LoadText(new TextSample() { Text = text, Source = "Bogus" }); var enumerator = StringInfo.GetTextElementEnumerator(text); @@ -216,45 +211,63 @@ public async Task ProcessKeyPress_WithEmoji_HandlesGraphemesCorrectly() { // Arrange: emoji with modifier (👍🏽) var emojiText = "👍🏽"; - var game = new TypingSession(_defaultOptions, _logger, TimeProvider.System); - game.LoadText(new TextSample { Text = emojiText, Source = "test" }); + var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + sut.LoadText(new TextSample { Text = emojiText, Source = "test" }); // Act: process the emoji as a single grapheme - game.ProcessKeyPress("👍🏽", false); + sut.ProcessKeyPress("👍🏽", false); // Assert: should count as exactly 1 correct keystroke - await Assert.That(game.Stats.Keystrokes.Count).IsEqualTo(1); - await Assert.That(game.Stats.Keystrokes[0].Grapheme).IsEqualTo("👍🏽"); - await Assert.That(game.Stats.Keystrokes[0].Type).IsEqualTo(KeystrokeType.Correct); + await Assert.That(sut.Stats.Keystrokes.Count).IsEqualTo(1); + await Assert.That(sut.Stats.Keystrokes[0].Grapheme).IsEqualTo("👍🏽"); + await Assert.That(sut.Stats.Keystrokes[0].Type).IsEqualTo(KeystrokeType.Correct); } - // [Test] - // [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] - // public async Task Engine_ShouldHandleWordsInInternationalLocales(string locale) - // { - // var faker = new Faker(locale); - // var internationalText = faker.Random.Words(10); - // var _engine = new GameEngine(_defaultOptions, _logger); - // _engine.LoadText(new TextSample() { Text = internationalText, Source = locale }); - - // var visualCount = new StringInfo( - // internationalText.Normalize(NormalizationForm.FormC) - // ).LengthInTextElements; - // await Assert.That(visualCount).IsEqualTo(_engine.CharacterStates.Count); - // } - - // [Test] - // [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] - // public async Task Engine_ShouldHandleSentencesInInternationalLocales(string locale) - // { - // var faker = new Faker(locale); - // var _engine = new GameEngine(_defaultOptions, _logger); - // var textSample = new TextSample() { Text = faker.Lorem.Sentence(), Source = locale }; - // _engine.LoadText(textSample); - // await _testLogger.LogDebugAsync(textSample.ToString()); - // var visualCount = new StringInfo( - // textSample.Text.Normalize(NormalizationForm.FormC) - // ).LengthInTextElements; - // await Assert.That(visualCount).IsEqualTo(_engine.CharacterStates.Count); - // } + [Test] + [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] + public async Task Engine_ShouldHandleWordsInInternationalLocales(string locale) + { + var faker = new Faker(locale); + var internationalText = faker.Random.Words(10); + var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + sut.LoadText(new TextSample() { Text = internationalText, Source = locale }); + + // Act + var enumerator = StringInfo.GetTextElementEnumerator(internationalText); + int graphemeCount = 0; + + while (enumerator.MoveNext()) + { + string grapheme = enumerator.GetTextElement(); + + sut.ProcessKeyPress(grapheme, false); + graphemeCount++; + } + + await Assert.That(sut.Stats.Keystrokes.Count).IsEqualTo(graphemeCount); + } + + [Test] + [MethodDataSource(typeof(TestDataSources), nameof(TestDataSources.AdditionTestData))] + public async Task Engine_ShouldHandleSentencesInInternationalLocales(string locale) + { + var faker = new Faker(locale); + var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + var textSample = new TextSample() { Text = faker.Lorem.Sentence(), Source = locale }; + sut.LoadText(textSample); + + // Act + var enumerator = StringInfo.GetTextElementEnumerator(textSample.Text); + int graphemeCount = 0; + + while (enumerator.MoveNext()) + { + string grapheme = enumerator.GetTextElement(); + + sut.ProcessKeyPress(grapheme, false); + graphemeCount++; + } + + await Assert.That(sut.Stats.Keystrokes.Count).IsEqualTo(graphemeCount); + } } From b70ab7f55a93d2219bf80c1ae62638ec80dde5f6 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 20 May 2026 10:20:08 +0100 Subject: [PATCH 17/58] fix: replace WeakReferenceMessenger calls with IMessenger --- src/Typical.Core/ViewModels/MainViewModel.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index f760996..7fd7c4b 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -12,6 +12,7 @@ public sealed partial class MainViewModel : ObservableObject, IRecipient _logger; + private readonly IMessenger _messenger; [ObservableProperty] public partial string AppTitle { get; set; } = "Typical"; @@ -25,17 +26,16 @@ public sealed partial class MainViewModel : ObservableObject, IRecipient logger + ILogger logger, + IMessenger messenger ) { _navigationService = navigationService; _dialogService = dialogService; _logger = logger; + _messenger = messenger; - WeakReferenceMessenger.Default.Register( - this, - (r, m) => r.Receive(m) - ); + _messenger.Register(this, (r, m) => r.Receive(m)); } [RelayCommand] From 0fdd9e81a41ff6ad585367c37e2543911c02f89b Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 20 May 2026 10:24:35 +0100 Subject: [PATCH 18/58] wip: stats persistence --- src/Typical.Core/Data/IStatsRepository.cs | 8 ++++ src/Typical.Core/Data/ITextRepository.cs | 10 +++++ src/Typical.Core/Data/Quote.cs | 9 ----- .../Events/GameStatsUpdatedMessage.cs | 2 + .../Services/ServiceExtensions.cs | 2 +- .../Statistics/GameStatsSnapshot.cs | 10 +++++ src/Typical.Core/TypingSession.cs | 33 ++++++++------- src/Typical.Core/ViewModels/StatsViewModel.cs | 12 +++--- .../ViewModels/TypingViewModel.cs | 40 ++++++++++--------- src/Typical.DataAccess/ServiceExtensions.cs | 1 + .../Sqlite/SimpleStatsRepository.cs | 17 ++++++++ src/Typical/Services/NavigationService.cs | 8 +--- 12 files changed, 97 insertions(+), 55 deletions(-) create mode 100644 src/Typical.Core/Data/IStatsRepository.cs create mode 100644 src/Typical.Core/Data/ITextRepository.cs create mode 100644 src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs diff --git a/src/Typical.Core/Data/IStatsRepository.cs b/src/Typical.Core/Data/IStatsRepository.cs new file mode 100644 index 0000000..4a027e1 --- /dev/null +++ b/src/Typical.Core/Data/IStatsRepository.cs @@ -0,0 +1,8 @@ +using Typical.Core.Statistics; + +namespace Typical.Core.Data; + +public interface IStatsRepository +{ + Task SaveGameResultAsync(GameResult result); +} diff --git a/src/Typical.Core/Data/ITextRepository.cs b/src/Typical.Core/Data/ITextRepository.cs new file mode 100644 index 0000000..b11d142 --- /dev/null +++ b/src/Typical.Core/Data/ITextRepository.cs @@ -0,0 +1,10 @@ +namespace Typical.Core.Data; + +public interface ITextRepository +{ + Task GetRandomQuoteAsync(); + Task GetQuoteAsync(int currentId); + Task GetQuoteByIdAsync(int id); + Task AddQuotesAsync(IEnumerable quotes); + Task HasAnyAsync(); +} diff --git a/src/Typical.Core/Data/Quote.cs b/src/Typical.Core/Data/Quote.cs index df93e4d..150566d 100644 --- a/src/Typical.Core/Data/Quote.cs +++ b/src/Typical.Core/Data/Quote.cs @@ -9,12 +9,3 @@ public class Quote public int WordCount { get; set; } public int CharCount { get; set; } } - -public interface ITextRepository -{ - Task GetRandomQuoteAsync(); - Task GetQuoteAsync(int currentId); - Task GetQuoteByIdAsync(int id); - Task AddQuotesAsync(IEnumerable quotes); - Task HasAnyAsync(); -} diff --git a/src/Typical.Core/Events/GameStatsUpdatedMessage.cs b/src/Typical.Core/Events/GameStatsUpdatedMessage.cs index d48b8db..9d469f8 100644 --- a/src/Typical.Core/Events/GameStatsUpdatedMessage.cs +++ b/src/Typical.Core/Events/GameStatsUpdatedMessage.cs @@ -6,6 +6,8 @@ public record GameStatsUpdatedMessage( GameStatsSnapshot State ); +public record GameCompletedMessage(GameResult Result); + public record GameResetMessage(ModeSettings Settings); public record WordsMode(int Count, bool Punctuation, bool Numbers); diff --git a/src/Typical.Core/Services/ServiceExtensions.cs b/src/Typical.Core/Services/ServiceExtensions.cs index f512ba6..506aec7 100644 --- a/src/Typical.Core/Services/ServiceExtensions.cs +++ b/src/Typical.Core/Services/ServiceExtensions.cs @@ -14,7 +14,7 @@ public static void AddCoreServices(this IServiceCollection services) services.AddSingleton(GameOptions.Default); services.AddSingleton(); services.AddSingleton(); - services.AddTransient(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddSingleton(); diff --git a/src/Typical.Core/Statistics/GameStatsSnapshot.cs b/src/Typical.Core/Statistics/GameStatsSnapshot.cs index 823851f..a2ae7f4 100644 --- a/src/Typical.Core/Statistics/GameStatsSnapshot.cs +++ b/src/Typical.Core/Statistics/GameStatsSnapshot.cs @@ -1,8 +1,18 @@ using System.Diagnostics; +using Typical.Core.Text; using Vogen; namespace Typical.Core.Statistics; +public readonly record struct GameResult( + DateTime PlayedAt, + WPM FinalWpm, + Accuracy FinalAccuracy, + TimeSpan Duration, + TextSample TargetText, + IReadOnlyList Telemetry +); + public readonly record struct GameStatsSnapshot( WPM WPM, Accuracy Accuracy, diff --git a/src/Typical.Core/TypingSession.cs b/src/Typical.Core/TypingSession.cs index 95f4c3b..fa1efe8 100644 --- a/src/Typical.Core/TypingSession.cs +++ b/src/Typical.Core/TypingSession.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using System.Runtime.CompilerServices; using System.Text; using Microsoft.Extensions.Logging; using Typical.Core.Events; @@ -15,11 +16,8 @@ public class TypingSession private string[] _targetGraphemes = []; private readonly GameOptions _gameOptions; private readonly TimeProvider _timeProvider; - - // TODO: Add HeatmapCollector private readonly ILogger _logger; - - // private KeystrokeType[] _charStates = []; + public event EventHandler? OnSessionFinished; public TypingSession( GameOptions gameOptions, @@ -33,7 +31,6 @@ TimeProvider timeProvider _logger = logger; } - // public IReadOnlyList CharacterStates => _userInput.GetCharacterStates(); internal GameStats Stats { get; private set; } public string TargetText { get; private set; } = string.Empty; @@ -41,6 +38,8 @@ TimeProvider timeProvider public bool IsOver { get; private set; } public bool IsRunning => !IsOver && Stats.IsRunning; + public TextSample SampleNormalized { get; private set; } = TextSample.Empty; + public GameStatsSnapshot CreateSnapshot() => Stats.CreateSnapshot(); public bool ProcessKeyPress(string input, bool isBackspace) @@ -55,10 +54,8 @@ public bool ProcessKeyPress(string input, bool isBackspace) { if (_userInput.GraphemeCount > 0) { - // int indexToReset = _userInput.GraphemeCount - 1; _userInput.Pop(); - // _charStates[indexToReset] = KeystrokeType.Untyped; Stats.RecordBackspace(); } return true; @@ -77,7 +74,6 @@ public bool ProcessKeyPress(string input, bool isBackspace) if (!_gameOptions.ForbidIncorrectEntries || isCorrect) { _userInput.Push(normalizedInput); - // _charStates[currentPos] = type; CheckEndCondition(); } @@ -88,21 +84,31 @@ private void CheckEndCondition() { if (_userInput.GraphemeCount == _targetGraphemes.Length) { - // bool hasErrors = _charStates.Any(s => s == KeystrokeType.Incorrect); - if (_gameOptions.Require100Accuracy) + if (_gameOptions.Require100Accuracy && _userInput.ToString() != TargetText) { - if (_userInput.ToString() != TargetText) - return; + return; } IsOver = true; Stats.Stop(); + var snapshot = Stats.CreateSnapshot(); + var result = new GameResult( + DateTime.UtcNow, + snapshot.WPM, + snapshot.Accuracy, + snapshot.ElapsedTime, + SampleNormalized, + Stats.Keystrokes + ); + + OnSessionFinished?.Invoke(this, result); } } public void LoadText(TextSample sample) { TargetText = sample.Text.Normalize(NormalizationForm.FormC); + SampleNormalized = sample with { Text = sample.Text.Normalize(NormalizationForm.FormC) }; List list = []; var enumerator = StringInfo.GetTextElementEnumerator(TargetText); @@ -115,9 +121,6 @@ public void LoadText(TextSample sample) _targetGraphemes = list.ToArray(); _userInput.Clear(); - // _charStates = new KeystrokeType[_targetGraphemes.Length]; - // Array.Fill(_charStates, KeystrokeType.Untyped); - IsOver = false; Stats = new GameStats(_timeProvider); } diff --git a/src/Typical.Core/ViewModels/StatsViewModel.cs b/src/Typical.Core/ViewModels/StatsViewModel.cs index f8612ff..ef381e3 100644 --- a/src/Typical.Core/ViewModels/StatsViewModel.cs +++ b/src/Typical.Core/ViewModels/StatsViewModel.cs @@ -7,15 +7,15 @@ namespace Typical.Core.ViewModels; public partial class StatsViewModel : ObservableObject, IRecipient { + private readonly IMessenger _messenger; + [ObservableProperty] - public partial GameStatsSnapshot Stats { get; set; } + public partial GameStatsSnapshot Stats { get; set; } = GameStatsSnapshot.Empty; - public StatsViewModel() + public StatsViewModel(IMessenger messenger) { - WeakReferenceMessenger.Default.Register( - this, - (r, m) => r.Receive(m) - ); + _messenger = messenger; + _messenger.Register(this, (r, m) => r.Receive(m)); } public void Receive(GameStatsUpdatedMessage message) diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index 77cd102..f0dee2d 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -1,7 +1,9 @@ +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.Logging; +using Typical.Core.Data; using Typical.Core.Events; using Typical.Core.Interfaces; using Typical.Core.Statistics; @@ -14,8 +16,9 @@ public partial class TypingViewModel INavigatableView, IRecipient { - private readonly TypingSession _engine; + private readonly TypingSession _session; private readonly ITextProvider _textProvider; + private readonly IStatsRepository _statsRepository; private readonly INavigationService _navigationService; private readonly ILogger _logger; private readonly IMessenger _messenger; @@ -23,23 +26,20 @@ public partial class TypingViewModel [ObservableProperty] public required partial TextSample Target { get; set; } = TextSample.Empty; - // [ObservableProperty] - // private bool _isGameOver; - - // [ObservableProperty] - // public partial KeystrokeType[] DisplayStates { get; set; } = []; - [SetsRequiredMembers] public TypingViewModel( - TypingSession engine, + TypingSession session, ITextProvider textProvider, + IStatsRepository statsRepository, INavigationService navigationService, ILogger logger, IMessenger messenger ) { - _engine = engine; + _session = session; + _session.OnSessionFinished += async (s, result) => await HandleSessionFinished(result); _textProvider = textProvider; + _statsRepository = statsRepository; _navigationService = navigationService; _logger = logger; _messenger = messenger; @@ -47,7 +47,7 @@ IMessenger messenger _messenger.Register(this, (r, m) => r.Receive(m)); } - public bool IsGameOver => _engine.IsOver; + public bool IsGameOver => _session.IsOver; /// /// Processes input received from the View. @@ -55,15 +55,14 @@ IMessenger messenger /// public async void ProcessInput(string c, bool isBackspace) { - if (_engine.IsOver) + if (_session.IsOver) { await InitializeAsync(); return; } - bool accepted = _engine.ProcessKeyPress(c, isBackspace); + bool accepted = _session.ProcessKeyPress(c, isBackspace); - // DisplayStates = _engine.CharacterStates.ToArray(); UpdateState(); } @@ -75,7 +74,7 @@ public async void ProcessInput(string c, bool isBackspace) /// private void UpdateState() { - var snapshot = _engine.CreateSnapshot(); + var snapshot = _session.CreateSnapshot(); _messenger.Send(new GameStatsUpdatedMessage(snapshot)); } @@ -92,9 +91,7 @@ public void OnNavigatedFrom() public async Task InitializeAsync(TextSample? textSample = null) { Target = textSample ?? await _textProvider.GetQuoteAsync(); - _engine.LoadText(Target); - // DisplayStates = new KeystrokeType[Target.Text.Length]; - // Array.Fill(DisplayStates, KeystrokeType.Untyped); + _session.LoadText(Target); UpdateState(); } @@ -113,6 +110,13 @@ public async void Receive(GameResetMessage message) public KeystrokeType GetStatus(int globalIdx) { - return _engine.GetStatus(globalIdx); + return _session.GetStatus(globalIdx); + } + + private async Task HandleSessionFinished(GameResult result) + { + await _statsRepository.SaveGameResultAsync(result); + + _messenger.Send(new GameCompletedMessage(result)); } } diff --git a/src/Typical.DataAccess/ServiceExtensions.cs b/src/Typical.DataAccess/ServiceExtensions.cs index d420292..6bd25a6 100644 --- a/src/Typical.DataAccess/ServiceExtensions.cs +++ b/src/Typical.DataAccess/ServiceExtensions.cs @@ -18,6 +18,7 @@ IConfiguration config var options = section.Get(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs new file mode 100644 index 0000000..7c7b819 --- /dev/null +++ b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; +using Typical.Core.Data; +using Typical.Core.Statistics; + +namespace Typical.DataAccess.Sqlite; + +public class SimpleStatsRepository : IStatsRepository +{ + public Task SaveGameResultAsync(GameResult result) + { + Debug.WriteLine("SimpleStatsRepository"); + + Debug.WriteLine(result.ToString()); + + return Task.CompletedTask; + } +} diff --git a/src/Typical/Services/NavigationService.cs b/src/Typical/Services/NavigationService.cs index d15efbe..17f0c12 100644 --- a/src/Typical/Services/NavigationService.cs +++ b/src/Typical/Services/NavigationService.cs @@ -15,15 +15,11 @@ public class NavigationService : ObservableObject, INavigationService private readonly IApplication _app; private readonly IMessenger _messenger; - public NavigationService( - IServiceProvider services, - IApplication app, - IMessenger? messenger = null - ) + public NavigationService(IServiceProvider services, IApplication app, IMessenger messenger) { _services = services; _app = app; - _messenger = messenger ?? WeakReferenceMessenger.Default; + _messenger = messenger; } private ObservableObject? _currentViewModel; From b4796e407be7de3ccab29767f8758c0a802b38cd Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 20 May 2026 10:33:47 +0100 Subject: [PATCH 19/58] refactor typing view refresh logic --- .../ViewModels/TypingViewModel.cs | 21 +++++++++++++++++++ src/Typical/Views/TypingView.cs | 17 +++------------ 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index f0dee2d..cb45988 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Timers; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.Logging; @@ -8,6 +9,7 @@ using Typical.Core.Interfaces; using Typical.Core.Statistics; using Typical.Core.Text; +using Timer = System.Timers.Timer; namespace Typical.Core.ViewModels; @@ -22,6 +24,9 @@ public partial class TypingViewModel private readonly INavigationService _navigationService; private readonly ILogger _logger; private readonly IMessenger _messenger; + private readonly Timer _refreshTimer; + + public event EventHandler? RefreshRequested; [ObservableProperty] public required partial TextSample Target { get; set; } = TextSample.Empty; @@ -44,6 +49,10 @@ IMessenger messenger _logger = logger; _messenger = messenger; + _refreshTimer = new Timer(100); + _refreshTimer.AutoReset = true; + _refreshTimer.Elapsed += OnRefreshTimerElapsed; + _messenger.Register(this, (r, m) => r.Receive(m)); } @@ -81,11 +90,23 @@ private void UpdateState() public void OnNavigatedTo() { _logger.LogInformation($"Navigated to {nameof(TypingViewModel)}"); + _refreshTimer.Start(); } public void OnNavigatedFrom() { _logger.LogInformation($"Navigated from {nameof(TypingViewModel)}"); + _refreshTimer.Stop(); + } + + private void OnRefreshTimerElapsed(object? sender, ElapsedEventArgs e) + { + if (_session.IsOver) + { + return; + } + + RefreshRequested?.Invoke(this, EventArgs.Empty); } public async Task InitializeAsync(TextSample? textSample = null) diff --git a/src/Typical/Views/TypingView.cs b/src/Typical/Views/TypingView.cs index 7de9f6f..dd75035 100644 --- a/src/Typical/Views/TypingView.cs +++ b/src/Typical/Views/TypingView.cs @@ -1,6 +1,5 @@ using System.ComponentModel; using System.Text; -using System.Timers; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; @@ -12,7 +11,6 @@ public class TypingView : BindableView { private readonly TypingArea _typingArea; private readonly Label _sourceLabel; - private readonly System.Timers.Timer _refreshTimer = new(100); public TypingView(TypingViewModel viewModel) : base(viewModel) @@ -33,13 +31,11 @@ public TypingView(TypingViewModel viewModel) _sourceLabel = new Label(); Add(_typingArea); - _refreshTimer.AutoReset = true; - _refreshTimer.Elapsed += OnRefreshTimerElapsed; + ViewModel.RefreshRequested += OnViewModelRefreshRequested; Initialized += (s, e) => { _ = InitializeViewAsync(); - _refreshTimer.Start(); }; this.Activating += (s, e) => { @@ -48,13 +44,8 @@ public TypingView(TypingViewModel viewModel) }; } - private void OnRefreshTimerElapsed(object? sender, ElapsedEventArgs e) + private void OnViewModelRefreshRequested(object? sender, EventArgs e) { - if (ViewModel.IsGameOver) - { - return; - } - App?.Invoke(() => ViewModel.RefreshState()); } @@ -80,7 +71,6 @@ protected override bool OnKeyDown(Key key) if (rune != default || isBackspace) { - char c = isBackspace ? '\0' : (char)rune.Value; try { ViewModel.ProcessInput(key.AsGrapheme, isBackspace); @@ -112,8 +102,7 @@ protected override void Dispose(bool disposing) { if (disposing) { - _refreshTimer.Stop(); - _refreshTimer.Dispose(); + ViewModel.RefreshRequested -= OnViewModelRefreshRequested; } base.Dispose(disposing); From 56a34cdaabb412bca6aaae47f27a56b9ce1cfe5b Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 20 May 2026 10:40:19 +0100 Subject: [PATCH 20/58] chore: fix tests --- .../Core/ViewModels/StatsViewModelTests.cs | 2 +- .../Core/ViewModels/TypingViewModelTests.cs | 17 +++++++++++------ src/Typical.Tests/UI/NavigationServiceTests.cs | 4 +++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs index db31013..a3062f4 100644 --- a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs @@ -15,7 +15,7 @@ public async Task Receive_GameStatsUpdatedMessage_UpdatesProperties() services.AddSingleton(); var provider = services.BuildServiceProvider(); var messenger = provider.GetRequiredService(); - var sut = new StatsViewModel(); + var sut = new StatsViewModel(messenger); // Manually register with the test messenger messenger.Register(sut, (r, m) => r.Receive(m)); diff --git a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs index 0d84895..785e700 100644 --- a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs @@ -2,6 +2,7 @@ using Imposter.Abstractions; using Microsoft.Extensions.Logging.Abstractions; using Typical.Core; +using Typical.Core.Data; using Typical.Core.Events; using Typical.Core.Interfaces; using Typical.Core.Text; @@ -9,11 +10,17 @@ [assembly: GenerateImposter(typeof(IMessenger))] [assembly: GenerateImposter(typeof(INavigationService))] +[assembly: GenerateImposter(typeof(IStatsRepository))] namespace Typical.Tests.Core.ViewModels; public class TypingViewModelTests { + private readonly IMessengerImposter mockMessenger = IMessenger.Imposter(); + private readonly INavigationServiceImposter mockNavigationService = + INavigationService.Imposter(); + private readonly IStatsRepositoryImposter mockStatsRepository = IStatsRepository.Imposter(); + [Test] public async Task InitializeAsync_LoadsQuote_FromTextProvider() { @@ -24,11 +31,11 @@ public async Task InitializeAsync_LoadsQuote_FromTextProvider() NullLogger.Instance, TimeProvider.System ); - var mockMessenger = IMessenger.Imposter(); - var mockNavigationService = INavigationService.Imposter(); + var vm = new TypingViewModel( engine, mockTextProvider, + mockStatsRepository.Instance(), mockNavigationService.Instance(), NullLogger.Instance, mockMessenger.Instance() @@ -47,13 +54,10 @@ public async Task ProcessInput_UpdatesEngine_AndBroadcastsMessage() NullLogger.Instance, TimeProvider.System ); - var mockMessenger = IMessenger.Imposter(); - - var messenger = mockMessenger.Instance(); - var mockNavigationService = INavigationService.Imposter(); var vm = new TypingViewModel( engine, mockTextProvider, + mockStatsRepository.Instance(), mockNavigationService.Instance(), NullLogger.Instance, mockMessenger.Instance() @@ -79,6 +83,7 @@ public async Task Receive_GameResetMessage_ReloadsText_BasedOnSettings() var vm = new TypingViewModel( engine, mockTextProvider, + mockStatsRepository.Instance(), mockNavigationService.Instance(), NullLogger.Instance, mockMessenger.Instance() diff --git a/src/Typical.Tests/UI/NavigationServiceTests.cs b/src/Typical.Tests/UI/NavigationServiceTests.cs index 2229b0c..458dfbe 100644 --- a/src/Typical.Tests/UI/NavigationServiceTests.cs +++ b/src/Typical.Tests/UI/NavigationServiceTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Terminal.Gui.ViewBase; using Typical.Core; +using Typical.Core.Data; using Typical.Core.Events; using Typical.Core.Interfaces; using Typical.Core.Text; @@ -32,9 +33,10 @@ public void Setup() var dialogServiceMock = IDialogService.Imposter(); var textProviderMock = ITextProvider.Imposter(); - + var statsRepoMock = IStatsRepository.Imposter(); services.AddSingleton(dialogServiceMock.Instance()); services.AddSingleton(textProviderMock.Instance()); + services.AddSingleton(statsRepoMock.Instance()); services.AddSingleton(sp => new TypingSession( new GameOptions(), From a0d7dfa0ed91be78255f69d5573c708ed3e22136 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Thu, 21 May 2026 07:57:39 +0100 Subject: [PATCH 21/58] test: update --- .../Core/ViewModels/StatsViewModelTests.cs | 3 --- src/Typical.Tests/UI/NavigationServiceTests.cs | 14 +++++++------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs index a3062f4..dfaa22d 100644 --- a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs @@ -17,9 +17,6 @@ public async Task Receive_GameStatsUpdatedMessage_UpdatesProperties() var messenger = provider.GetRequiredService(); var sut = new StatsViewModel(messenger); - // Manually register with the test messenger - messenger.Register(sut, (r, m) => r.Receive(m)); - var fakeSnapshot = new GameStatsSnapshot( WPM: (WPM)65.8, Accuracy: (Accuracy)98.5, diff --git a/src/Typical.Tests/UI/NavigationServiceTests.cs b/src/Typical.Tests/UI/NavigationServiceTests.cs index 458dfbe..fb78a09 100644 --- a/src/Typical.Tests/UI/NavigationServiceTests.cs +++ b/src/Typical.Tests/UI/NavigationServiceTests.cs @@ -19,6 +19,9 @@ namespace Typical.Tests.UI; public class NavigationServiceTests { + private readonly IDialogServiceImposter _dialogServiceMock = IDialogService.Imposter(); + private readonly ITextProviderImposter _textProviderMock = ITextProvider.Imposter(); + private readonly IStatsRepositoryImposter _statsRepoMock = IStatsRepository.Imposter(); private ServiceProvider _serviceProvider = null!; private INavigationService _navigationService = null!; private IMessenger _messenger = null!; @@ -31,12 +34,9 @@ public void Setup() _messenger = new StrongReferenceMessenger(); services.AddSingleton(_messenger); - var dialogServiceMock = IDialogService.Imposter(); - var textProviderMock = ITextProvider.Imposter(); - var statsRepoMock = IStatsRepository.Imposter(); - services.AddSingleton(dialogServiceMock.Instance()); - services.AddSingleton(textProviderMock.Instance()); - services.AddSingleton(statsRepoMock.Instance()); + services.AddSingleton(_dialogServiceMock.Instance()); + services.AddSingleton(_textProviderMock.Instance()); + services.AddSingleton(_statsRepoMock.Instance()); services.AddSingleton(sp => new TypingSession( new GameOptions(), @@ -121,7 +121,7 @@ public async Task ViewLocator_MapsAllNavigatableViewModels() var viewModel = _serviceProvider.GetRequiredService(type); // Act - var view = Typical.Navigation.ViewLocator.GetView(_serviceProvider, viewModel); + var view = Navigation.ViewLocator.GetView(_serviceProvider, viewModel); // Assert await Assert.That(view).IsNotNull(); From c37a86bb40a2bb882c79bd5574341ac929f9d306 Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Fri, 22 May 2026 16:05:27 +0100 Subject: [PATCH 22/58] ci: update loom to 0.6.0 --- .build/loom.json | 1 + .build/loom.schema.json | 4 ++++ .config/dotnet-tools.json | 2 +- .github/dependabot.yml | 8 ++++++++ .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 4 ++-- 6 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.build/loom.json b/.build/loom.json index 47531eb..9790705 100644 --- a/.build/loom.json +++ b/.build/loom.json @@ -6,6 +6,7 @@ "cleanDirectories": [], "enableNugetUpload": false, "enableGithubRelease": true, + "defaultPreReleaseIdentifiers": "preview.0", "enableVelopackRelease": true }, "artifacts": { diff --git a/.build/loom.schema.json b/.build/loom.schema.json index e8f8d24..4e5a842 100644 --- a/.build/loom.schema.json +++ b/.build/loom.schema.json @@ -69,6 +69,10 @@ "type": "boolean", "description": "Whether to create a GitHub release during a release." }, + "defaultPreReleaseIdentifiers": { + "type": "string", + "description": "Maps to MinVer.DefaultPreReleaseIdentifiers" + }, "enableVelopackRelease": { "type": "boolean", "description": "Whether to create velopack packages during a release." diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index c9f2365..1b7413a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "loom.build": { - "version": "0.5.0", + "version": "0.6.0", "commands": [ "loom" ], diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9c35a64..9f464a9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,10 @@ updates: interval: weekly day: monday open-pull-requests-limit: 5 + groups: + nuget-dependencies: + patterns: + - "*" - package-ecosystem: github-actions directory: "/" @@ -14,3 +18,7 @@ updates: interval: weekly day: monday open-pull-requests-limit: 5 + groups: + github-actions-dependencies: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 111bf61..c69eb92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: run: dotnet tool restore - name: Run Loom Test Pipeline - run: dotnet loom Test + run: dotnet loom test - name: Upload test results if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e858f3..ce9960c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: run: dotnet tool restore - name: Run tests - run: dotnet loom Test + run: dotnet loom test release: name: Release Artifacts for ${{ matrix.rid }} @@ -73,4 +73,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} Nuget__ApiKey: ${{ secrets.NUGET_API_KEY }} - run: dotnet loom Release + run: dotnet loom release From d67b5fccb065a048b29972a59e797dc6a22d6267 Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Fri, 22 May 2026 17:32:12 +0100 Subject: [PATCH 23/58] wip: ResultsView --- .../Events/GameStatsUpdatedMessage.cs | 4 - .../Events/SessionCompletedMessage.cs | 5 + .../Events/SessionResetMessage.cs | 3 + .../Interfaces/INavigationService.cs | 2 + .../Services/ServiceExtensions.cs | 1 + src/Typical.Core/ViewModels/MainViewModel.cs | 15 +- .../ViewModels/ResultsViewModel.cs | 13 ++ .../ViewModels/SettingsViewModel.cs | 2 +- .../ViewModels/TypingViewModel.cs | 23 ++- .../Core/ViewModels/TypingViewModelTests.cs | 2 +- src/Typical/Navigation/ViewLocator.cs | 7 + src/Typical/Services/NavigationService.cs | 15 ++ src/Typical/Services/ServiceExtensions.cs | 2 + src/Typical/Views/MainShell.cs | 4 + todo.md | 140 ++++++++++++++++++ 15 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 src/Typical.Core/Events/SessionCompletedMessage.cs create mode 100644 src/Typical.Core/Events/SessionResetMessage.cs create mode 100644 src/Typical.Core/ViewModels/ResultsViewModel.cs diff --git a/src/Typical.Core/Events/GameStatsUpdatedMessage.cs b/src/Typical.Core/Events/GameStatsUpdatedMessage.cs index 9d469f8..518fcef 100644 --- a/src/Typical.Core/Events/GameStatsUpdatedMessage.cs +++ b/src/Typical.Core/Events/GameStatsUpdatedMessage.cs @@ -6,10 +6,6 @@ public record GameStatsUpdatedMessage( GameStatsSnapshot State ); -public record GameCompletedMessage(GameResult Result); - -public record GameResetMessage(ModeSettings Settings); - public record WordsMode(int Count, bool Punctuation, bool Numbers); public record TimeMode(TimeSpan Duration, bool Punctuation, bool Numbers); public record QuoteMode(QuoteLength Length); diff --git a/src/Typical.Core/Events/SessionCompletedMessage.cs b/src/Typical.Core/Events/SessionCompletedMessage.cs new file mode 100644 index 0000000..50fd605 --- /dev/null +++ b/src/Typical.Core/Events/SessionCompletedMessage.cs @@ -0,0 +1,5 @@ +using Typical.Core.Statistics; + +namespace Typical.Core.Events; + +public record SessionCompletedMessage(GameResult Result); diff --git a/src/Typical.Core/Events/SessionResetMessage.cs b/src/Typical.Core/Events/SessionResetMessage.cs new file mode 100644 index 0000000..d8f8f25 --- /dev/null +++ b/src/Typical.Core/Events/SessionResetMessage.cs @@ -0,0 +1,3 @@ +namespace Typical.Core.Events; + +public record SessionResetMessage(ModeSettings Settings); diff --git a/src/Typical.Core/Interfaces/INavigationService.cs b/src/Typical.Core/Interfaces/INavigationService.cs index f7db03a..56a7fff 100644 --- a/src/Typical.Core/Interfaces/INavigationService.cs +++ b/src/Typical.Core/Interfaces/INavigationService.cs @@ -8,6 +8,8 @@ public interface INavigationService : INotifyPropertyChanged ObservableObject CurrentViewModel { get; } void NavigateTo() where TViewModel : ObservableObject; + void NavigateTo(Action configure) + where TViewModel : ObservableObject; TResult? ShowModal(Action? configure = null) where TViewModel : class, IModalViewModel; } diff --git a/src/Typical.Core/Services/ServiceExtensions.cs b/src/Typical.Core/Services/ServiceExtensions.cs index 506aec7..6dadb3b 100644 --- a/src/Typical.Core/Services/ServiceExtensions.cs +++ b/src/Typical.Core/Services/ServiceExtensions.cs @@ -18,5 +18,6 @@ public static void AddCoreServices(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index 7fd7c4b..968440f 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -7,7 +7,7 @@ namespace Typical.Core.ViewModels; -public sealed partial class MainViewModel : ObservableObject, IRecipient +public sealed partial class MainViewModel : ObservableObject, IRecipient { private readonly INavigationService _navigationService; private readonly IDialogService _dialogService; @@ -31,11 +31,18 @@ IMessenger messenger ) { _navigationService = navigationService; + _navigationService.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(navigationService.CurrentViewModel)) + { + CurrentPage = _navigationService.CurrentViewModel; + } + }; _dialogService = dialogService; _logger = logger; _messenger = messenger; - _messenger.Register(this, (r, m) => r.Receive(m)); + _messenger.Register(this, (r, m) => r.Receive(m)); } [RelayCommand] @@ -53,8 +60,8 @@ private void ShowAbout() _dialogService.ShowError("About", "Typical: A Terminal.Gui v2 MVVM Demo"); } - public void Receive(NavigationChangedMessage message) + public void Receive(SessionCompletedMessage message) { - CurrentPage = message.Value; + _navigationService.NavigateTo(vm => vm.Initialize(message.Result)); } } diff --git a/src/Typical.Core/ViewModels/ResultsViewModel.cs b/src/Typical.Core/ViewModels/ResultsViewModel.cs new file mode 100644 index 0000000..23e6377 --- /dev/null +++ b/src/Typical.Core/ViewModels/ResultsViewModel.cs @@ -0,0 +1,13 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Typical.Core.Statistics; + +namespace Typical.Core.ViewModels; + +public class ResultsViewModel : ObservableObject +{ + public void Initialize(GameResult result) + { + // TODO: finish implementing display of results + throw new NotImplementedException(); + } +} diff --git a/src/Typical.Core/ViewModels/SettingsViewModel.cs b/src/Typical.Core/ViewModels/SettingsViewModel.cs index 9fa0435..9fb4dbc 100644 --- a/src/Typical.Core/ViewModels/SettingsViewModel.cs +++ b/src/Typical.Core/ViewModels/SettingsViewModel.cs @@ -33,7 +33,7 @@ IMessenger messenger [RelayCommand] private void QuoteMode() { - var message = new GameResetMessage(new QuoteMode(QuoteLength.Medium)); + var message = new SessionResetMessage(new QuoteMode(QuoteLength.Medium)); _messenger.Send(message); } diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index cb45988..8635a8e 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -16,15 +16,15 @@ namespace Typical.Core.ViewModels; public partial class TypingViewModel : ObservableObject, INavigatableView, - IRecipient + IRecipient { private readonly TypingSession _session; private readonly ITextProvider _textProvider; private readonly IStatsRepository _statsRepository; - private readonly INavigationService _navigationService; private readonly ILogger _logger; private readonly IMessenger _messenger; private readonly Timer _refreshTimer; + private bool _isFinishing; public event EventHandler? RefreshRequested; @@ -45,7 +45,6 @@ IMessenger messenger _session.OnSessionFinished += async (s, result) => await HandleSessionFinished(result); _textProvider = textProvider; _statsRepository = statsRepository; - _navigationService = navigationService; _logger = logger; _messenger = messenger; @@ -53,7 +52,7 @@ IMessenger messenger _refreshTimer.AutoReset = true; _refreshTimer.Elapsed += OnRefreshTimerElapsed; - _messenger.Register(this, (r, m) => r.Receive(m)); + _messenger.Register(this, (r, m) => r.Receive(m)); } public bool IsGameOver => _session.IsOver; @@ -116,7 +115,7 @@ public async Task InitializeAsync(TextSample? textSample = null) UpdateState(); } - public async void Receive(GameResetMessage message) + public async void Receive(SessionResetMessage message) { TextSample textSample = message.Settings switch { @@ -136,8 +135,18 @@ public KeystrokeType GetStatus(int globalIdx) private async Task HandleSessionFinished(GameResult result) { - await _statsRepository.SaveGameResultAsync(result); + if (_isFinishing) + return; + _isFinishing = true; - _messenger.Send(new GameCompletedMessage(result)); + try + { + await _statsRepository.SaveGameResultAsync(result); + _messenger.Send(new SessionCompletedMessage(result)); + } + finally + { + _isFinishing = false; + } } } diff --git a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs index 785e700..82aa7ec 100644 --- a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs @@ -88,7 +88,7 @@ public async Task Receive_GameResetMessage_ReloadsText_BasedOnSettings() NullLogger.Instance, mockMessenger.Instance() ); - var msg = new GameResetMessage(new QuoteMode(QuoteLength.Short)); + var msg = new SessionResetMessage(new QuoteMode(QuoteLength.Short)); vm.Receive(msg); await Task.Delay(10); // Allow async to complete await Assert.That(vm.Target.Text).IsEqualTo("reset quote"); diff --git a/src/Typical/Navigation/ViewLocator.cs b/src/Typical/Navigation/ViewLocator.cs index 172912d..6550766 100644 --- a/src/Typical/Navigation/ViewLocator.cs +++ b/src/Typical/Navigation/ViewLocator.cs @@ -13,6 +13,13 @@ public static View GetView(IServiceProvider sp, object viewModel) => HomeViewModel => sp.GetRequiredService(), SettingsViewModel => sp.GetRequiredService(), TypingViewModel => sp.GetRequiredService(), + ResultsViewModel => sp.GetRequiredService(), _ => throw new ArgumentException($"No view registered for {viewModel.GetType()}"), }; } + +public class ResultsView : BindableView +{ + public ResultsView(ResultsViewModel viewModel) + : base(viewModel) { } +} diff --git a/src/Typical/Services/NavigationService.cs b/src/Typical/Services/NavigationService.cs index 17f0c12..83b4aef 100644 --- a/src/Typical/Services/NavigationService.cs +++ b/src/Typical/Services/NavigationService.cs @@ -42,6 +42,21 @@ public void NavigateTo() _messenger.Send(new NavigationChangedMessage(CurrentViewModel)); } + public void NavigateTo(Action configure) + where TViewModel : ObservableObject + { + (CurrentViewModel as INavigatableView)?.OnNavigatedFrom(); + var nextViewModel = _services.GetRequiredService(); + + configure?.Invoke(nextViewModel); + + CurrentViewModel = nextViewModel; + + (CurrentViewModel as INavigatableView)?.OnNavigatedTo(); + + _messenger.Send(new NavigationChangedMessage(nextViewModel)); + } + public TResult? ShowModal(Action? configure = null) where TViewModel : class, IModalViewModel { diff --git a/src/Typical/Services/ServiceExtensions.cs b/src/Typical/Services/ServiceExtensions.cs index c2586de..316f895 100644 --- a/src/Typical/Services/ServiceExtensions.cs +++ b/src/Typical/Services/ServiceExtensions.cs @@ -9,6 +9,7 @@ using Typical.Configuration; using Typical.Core.Interfaces; using Typical.Logging; +using Typical.Navigation; using Typical.Views; namespace Typical.Services; @@ -57,6 +58,7 @@ public static void AddTuiInfrastructure(this HostApplicationBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); } } diff --git a/src/Typical/Views/MainShell.cs b/src/Typical/Views/MainShell.cs index 7fdb3d5..9b3e458 100644 --- a/src/Typical/Views/MainShell.cs +++ b/src/Typical/Views/MainShell.cs @@ -120,6 +120,10 @@ private void UpdateContent(ObservableObject? viewModel) if (viewModel == null) return; + foreach (var child in _contentFrame.SubViews) + { + child.Dispose(); + } _contentFrame.RemoveAll(); var view = ViewLocator.GetView(_serviceProvider, viewModel); diff --git a/todo.md b/todo.md index 24754ae..548f46d 100644 --- a/todo.md +++ b/todo.md @@ -14,3 +14,143 @@ - `change` brings up an options menu --- + +# AI Agent System Prompt & Execution Protocol + +**ROLE:** You are an expert .NET 11 C# developer adhering to strict Test-Driven Development (TDD) principles. +**STACK:** C# 12/13, .NET 11, CommunityToolkit.Mvvm, Terminal.Gui v2, TUnit, SQLite, DbUp. +**PROTOCOL:** + +1. Execute tasks strictly in the sequence provided. +2. For each task, read the target files, make the modifications, and run `dotnet test src/Typical.Tests/Typical.Tests.csproj`. +3. Do not proceed to the next task if the tests fail. Fix the compilation or logic error first. +4. Favor explicit Dependency Injection over static singletons. + +--- + +## 🛠️ Phase 1: Architectural Refactoring for Testability + +*Objective: Remove static coupling to allow deterministic testing of time and messaging.* + +### Task 1.1: Inject `TimeProvider` into Game Statistics + +- **Target File:** `src/Typical.Core/Statistics/GameStats.cs` +- **Action:** + - Change the parameterless constructor to accept `TimeProvider timeProvider`. + - Assign it to the `_timeProvider` readonly field. +- **Target File:** `src/Typical.Core/TypingSession.cs` +- **Action:** + - Update the constructor to accept `TimeProvider timeProvider`. + - Update the instantiation: `Stats = new GameStats(timeProvider);`. +- **Target File:** `src/Typical.Tests/TypingSessionTests.cs` +- **Action:** + - Fix compiler errors by passing `TimeProvider.System` (or a `FakeTimeProvider`) to `TypingSession` instantiations in the test setups. + +### Task 1.2: Abstract `IMessenger` in ViewModels + +- **Target Files:** + - `src/Typical.Core/ViewModels/TypingViewModel.cs` + - `src/Typical.Core/ViewModels/SettingsViewModel.cs` +- **Action:** + - Add `IMessenger messenger` to their constructors. + - Replace all instances of `WeakReferenceMessenger.Default.Send(...)` with `_messenger.Send(...)`. +- **Target File:** `src/Typical.Tests/NavigationServiceTests.cs` +- **Action:** + - Fix any compiler errors in the test setups by passing a mock/concrete `IMessenger` to the modified ViewModels. + +--- + +## 🧪 Phase 2: Core Domain Logic Tests + +*Objective: Implement tests for the currently empty GameStats test file.* + +### Task 2.1: WPM and Accuracy Math Tests + +- **Target File:** `src/Typical.Tests/Core/GameStatsTests.cs` +- **Dependencies to use:** `Microsoft.Extensions.TimeProvider.Testing` (`FakeTimeProvider`), `TUnit`. +- **Action:** Implement the following tests: + 1. `CreateSnapshot_CalculatesAccurateWPM_BasedOnTime`: + - **Setup:** Create `FakeTimeProvider`, pass to `GameStats`. Call `Start()`. + - **Action:** Record 6 correct characters (e.g., "hello "). Advance `FakeTimeProvider` by exactly 12 seconds. Call `Stop()`. + - **Assert:** `WPM` should equal `6` (6 chars / 5 = 1.2 words. 1.2 words in 0.2 minutes = 6 WPM). `Accuracy` should equal `100`. + 2. `CreateSnapshot_CalculatesAccuracy_WithErrors`: + - **Action:** Record 9 `Correct` keystrokes and 1 `Incorrect` keystroke. + - **Assert:** `Accuracy` equals `90`. + 3. `CreateSnapshot_HandlesZeroElapsed_PreventsDivideByZero`: + - **Setup:** Start and immediately stop without advancing time. + - **Assert:** Returns default snapshot without throwing an exception. + +--- + +## 🏗️ Phase 3: ViewModel Behavioral Tests + +*Objective: Test state mutations and message handling in ViewModels.* + +### Task 3.1: Implement StatsViewModel Tests + +- **Target File:** `src/Typical.Tests/StatsViewModelTests.cs` +- **Action:** Implement tests using TUnit: + 1. `Receive_GameStatsUpdatedMessage_UpdatesProperties`: + - **Setup:** Instantiate `StatsViewModel`. + - **Action:** Call `Receive(new GameStatsUpdatedMessage(mockSnapshot))`. + - **Assert:** Verify the ViewModels properties (WPM, Accuracy, ElapsedTime) map exactly to the snapshot's values. + +### Task 3.2: Create TypingViewModel Tests + +- **Target File:** `src/Typical.Tests/TypingViewModelTests.cs` (Create new file) +- **Action:** Implement tests: + 1. `InitializeAsync_LoadsQuote_FromTextProvider`: + - **Setup:** Mock `ITextProvider` to return a specific `TextSample`. + - **Action:** Call `InitializeAsync()`. + - **Assert:** Verify `ViewModel.Target` equals the mock text. + 2. `ProcessInput_UpdatesEngine_AndBroadcastsMessage`: + - **Setup:** Inject a mock `IMessenger`. Call `InitializeAsync()`. + - **Action:** Call `ProcessInput("a", false)`. + - **Assert:** Verify `_messenger.Send` was called with a `GameStatsUpdatedMessage`. + 3. `Receive_GameResetMessage_ReloadsText_BasedOnSettings`: + - **Action:** Call `Receive(new GameResetMessage(new QuoteMode(QuoteLength.Short)))`. + - **Assert:** Verify `ITextProvider.GetQuoteAsync(QuoteLength.Short)` was called. + +--- + +## 🗄️ Phase 4: Data Access Integration Tests + +*Objective: Test SQLite repositories against an in-memory database.* + +### Task 4.1: Create TextRepository Tests + +- **Target File:** `src/Typical.Tests/TextRepositoryTests.cs` (Create new file) +- **Constraint:** Do NOT mock the database. Use SQLite in-memory mode (`Data Source=:memory:`). +- **Setup Block:** + - Create an in-memory SQLite connection and open it. + - Instantiate `TypicalDbOptions` pointing to this connection string. + - Run the `DatabaseMigrator` to apply `Script_00100_CreateQuotesTable` and `Script_00200_SeedInitialQuotes`. +- **Action:** Implement tests: + 1. `GetRandomQuoteAsync_ReturnsSeededQuote`: + - **Action:** Call `GetRandomQuoteAsync()`. + - **Assert:** Result is not null, `Text` is populated, `Author` is parsed correctly. + 2. `GetQuoteAsync_MapsDBNullAuthor_ToUnknown`: + - **Action:** Insert a raw record into the DB with a `NULL` author. Retrieve it via `GetQuoteAsync()`. + - **Assert:** Verify `Quote.Author` equals `"Unknown"`. + +--- + +## 🚦 Phase 5: Edge Cases + +*Objective: Guarantee application stability during anomalous inputs.* + +### Task 5.1: Grapheme Cluster Boundary Tests + +- **Target File:** `src/Typical.Tests/TypingSessionTests.cs` +- **Action:** Add a test `ProcessKeyPress_WithEmoji_HandlesGraphemesCorrectly`: + - **Setup:** Load text containing an emoji with a modifier (e.g., `👍🏽`). + - **Action:** Call `ProcessKeyPress("👍🏽", false)`. + - **Assert:** Verify `GameStats.TotalPhysicalKeystrokes` counts this as exactly **1** correct keystroke, not multiple bytes. + +### Task 5.2: ViewLocator Completeness Test + +- **Target File:** `src/Typical.Tests/NavigationServiceTests.cs` (or create `ViewLocatorTests.cs`) +- **Action:** Add a test using Reflection: + - **Setup:** Find all classes in `Typical.Core` that implement `INavigatableView`. + - **Action:** Pass each to `ViewLocator.GetView(sp, instance)`. + - **Assert:** No `ArgumentException` is thrown (proving every Navigatable ViewModel has a mapped View in the UI project). From d315eaae304aaba9927d6c752a2a76592863fe16 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Sat, 23 May 2026 02:29:14 +0100 Subject: [PATCH 24/58] renames --- src/Typical.Core/Data/IStatsRepository.cs | 2 +- .../Events/GameStatsUpdatedMessage.cs | 4 +-- .../Events/SessionResetMessage.cs | 3 -- ...etedMessage.cs => TestCompletedMessage.cs} | 2 +- src/Typical.Core/Events/TestResetMessage.cs | 3 ++ src/Typical.Core/Logging/CoreLogs.cs | 2 +- .../Services/ServiceExtensions.cs | 2 +- .../{GameStats.cs => Statistics.cs} | 12 +++---- src/Typical.Core/Statistics/TestResult.cs | 12 +++++++ .../{GameStatsSnapshot.cs => TestSnapshot.cs} | 18 +++------- .../{TypingSession.cs => TypingTest.cs} | 22 ++++++------ src/Typical.Core/ViewModels/MainViewModel.cs | 6 ++-- .../ViewModels/ResultsViewModel.cs | 2 +- .../ViewModels/SettingsViewModel.cs | 2 +- src/Typical.Core/ViewModels/StatsViewModel.cs | 8 ++--- .../ViewModels/TypingViewModel.cs | 34 +++++++++--------- .../Sqlite/SimpleStatsRepository.cs | 2 +- .../Sqlite/StatsRepository.cs | 22 ++++++++++++ .../Core/Statistics/GameStatsTests.cs | 6 ++-- ...pingSessionTests.cs => TypingTestTests.cs} | 36 +++++++++---------- .../Core/ViewModels/StatsViewModelTests.cs | 4 +-- .../Core/ViewModels/TypingViewModelTests.cs | 14 ++++---- .../UI/NavigationServiceTests.cs | 4 +-- todo.md | 8 ++--- 24 files changed, 127 insertions(+), 103 deletions(-) delete mode 100644 src/Typical.Core/Events/SessionResetMessage.cs rename src/Typical.Core/Events/{SessionCompletedMessage.cs => TestCompletedMessage.cs} (52%) create mode 100644 src/Typical.Core/Events/TestResetMessage.cs rename src/Typical.Core/Statistics/{GameStats.cs => Statistics.cs} (82%) create mode 100644 src/Typical.Core/Statistics/TestResult.cs rename src/Typical.Core/Statistics/{GameStatsSnapshot.cs => TestSnapshot.cs} (69%) rename src/Typical.Core/{TypingSession.cs => TypingTest.cs} (86%) create mode 100644 src/Typical.DataAccess/Sqlite/StatsRepository.cs rename src/Typical.Tests/Core/Statistics/{TypingSessionTests.cs => TypingTestTests.cs} (86%) diff --git a/src/Typical.Core/Data/IStatsRepository.cs b/src/Typical.Core/Data/IStatsRepository.cs index 4a027e1..5e751f5 100644 --- a/src/Typical.Core/Data/IStatsRepository.cs +++ b/src/Typical.Core/Data/IStatsRepository.cs @@ -4,5 +4,5 @@ namespace Typical.Core.Data; public interface IStatsRepository { - Task SaveGameResultAsync(GameResult result); + Task SaveGameResultAsync(TestResult result); } diff --git a/src/Typical.Core/Events/GameStatsUpdatedMessage.cs b/src/Typical.Core/Events/GameStatsUpdatedMessage.cs index 518fcef..daf3d01 100644 --- a/src/Typical.Core/Events/GameStatsUpdatedMessage.cs +++ b/src/Typical.Core/Events/GameStatsUpdatedMessage.cs @@ -2,8 +2,8 @@ namespace Typical.Core.Events; -public record GameStatsUpdatedMessage( - GameStatsSnapshot State +public record StatisticsUpdatedMessage( + TestSnapshot State ); public record WordsMode(int Count, bool Punctuation, bool Numbers); diff --git a/src/Typical.Core/Events/SessionResetMessage.cs b/src/Typical.Core/Events/SessionResetMessage.cs deleted file mode 100644 index d8f8f25..0000000 --- a/src/Typical.Core/Events/SessionResetMessage.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Typical.Core.Events; - -public record SessionResetMessage(ModeSettings Settings); diff --git a/src/Typical.Core/Events/SessionCompletedMessage.cs b/src/Typical.Core/Events/TestCompletedMessage.cs similarity index 52% rename from src/Typical.Core/Events/SessionCompletedMessage.cs rename to src/Typical.Core/Events/TestCompletedMessage.cs index 50fd605..134f4d9 100644 --- a/src/Typical.Core/Events/SessionCompletedMessage.cs +++ b/src/Typical.Core/Events/TestCompletedMessage.cs @@ -2,4 +2,4 @@ namespace Typical.Core.Events; -public record SessionCompletedMessage(GameResult Result); +public record TestCompletedMessage(TestResult Result); diff --git a/src/Typical.Core/Events/TestResetMessage.cs b/src/Typical.Core/Events/TestResetMessage.cs new file mode 100644 index 0000000..a80f088 --- /dev/null +++ b/src/Typical.Core/Events/TestResetMessage.cs @@ -0,0 +1,3 @@ +namespace Typical.Core.Events; + +public record TestResetMessage(ModeSettings Settings); diff --git a/src/Typical.Core/Logging/CoreLogs.cs b/src/Typical.Core/Logging/CoreLogs.cs index 264a066..b8f1b3d 100644 --- a/src/Typical.Core/Logging/CoreLogs.cs +++ b/src/Typical.Core/Logging/CoreLogs.cs @@ -14,7 +14,7 @@ public static partial class CoreLogs Level = LogLevel.Information, Message = "Game finished successfully. {Stats}" )] - public static partial void GameFinished(ILogger logger, GameStatsSnapshot stats); + public static partial void GameFinished(ILogger logger, TestSnapshot stats); [LoggerMessage(EventId = 2002, Level = LogLevel.Information, Message = "Game quit by user.")] public static partial void GameQuit(ILogger logger); diff --git a/src/Typical.Core/Services/ServiceExtensions.cs b/src/Typical.Core/Services/ServiceExtensions.cs index 6dadb3b..fa8f794 100644 --- a/src/Typical.Core/Services/ServiceExtensions.cs +++ b/src/Typical.Core/Services/ServiceExtensions.cs @@ -12,7 +12,7 @@ public static void AddCoreServices(this IServiceCollection services) services.AddSingleton(TimeProvider.System); services.AddSingleton(); services.AddSingleton(GameOptions.Default); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddTransient(); diff --git a/src/Typical.Core/Statistics/GameStats.cs b/src/Typical.Core/Statistics/Statistics.cs similarity index 82% rename from src/Typical.Core/Statistics/GameStats.cs rename to src/Typical.Core/Statistics/Statistics.cs index a3c502d..adba405 100644 --- a/src/Typical.Core/Statistics/GameStats.cs +++ b/src/Typical.Core/Statistics/Statistics.cs @@ -1,21 +1,21 @@ namespace Typical.Core.Statistics; -public class GameStats +public class Statistics { private readonly TimeProvider _timeProvider; private readonly KeystrokeCollection _keystrokes = new(); - private readonly List _snapshots = []; + private readonly List _snapshots = []; private long? _startTimestamp; private long? _endTimestamp; - public GameStats(TimeProvider timeProvider) + public Statistics(TimeProvider timeProvider) { _timeProvider = timeProvider; } public IReadOnlyList Keystrokes => _keystrokes.GetLog(); - public IReadOnlyList Snapshots => _snapshots.AsReadOnly(); + public IReadOnlyList Snapshots => _snapshots.AsReadOnly(); public TimeSpan ElapsedTime => _startTimestamp.HasValue ? _timeProvider.GetElapsedTime( @@ -54,14 +54,14 @@ private void Reset() internal void Stop() => _endTimestamp = _timeProvider.GetTimestamp(); - public GameStatsSnapshot CreateSnapshot() + public TestSnapshot CreateSnapshot() { var characterStats = new CharacterStats( _keystrokes.CorrectCount, _keystrokes.ErrorCount, _keystrokes.CorrectionCount ); - var snapshot = GameStatsSnapshot.Create(characterStats, ElapsedTime); + var snapshot = TestSnapshot.Create(characterStats, ElapsedTime); _snapshots.Add(snapshot); diff --git a/src/Typical.Core/Statistics/TestResult.cs b/src/Typical.Core/Statistics/TestResult.cs new file mode 100644 index 0000000..aeacc27 --- /dev/null +++ b/src/Typical.Core/Statistics/TestResult.cs @@ -0,0 +1,12 @@ +using Typical.Core.Text; + +namespace Typical.Core.Statistics; + +public readonly record struct TestResult( + DateTime PlayedAt, + WPM FinalWpm, + Accuracy FinalAccuracy, + TimeSpan Duration, + TextSample TargetText, + IReadOnlyList Telemetry +); diff --git a/src/Typical.Core/Statistics/GameStatsSnapshot.cs b/src/Typical.Core/Statistics/TestSnapshot.cs similarity index 69% rename from src/Typical.Core/Statistics/GameStatsSnapshot.cs rename to src/Typical.Core/Statistics/TestSnapshot.cs index a2ae7f4..951b37a 100644 --- a/src/Typical.Core/Statistics/GameStatsSnapshot.cs +++ b/src/Typical.Core/Statistics/TestSnapshot.cs @@ -1,33 +1,23 @@ using System.Diagnostics; -using Typical.Core.Text; using Vogen; namespace Typical.Core.Statistics; -public readonly record struct GameResult( - DateTime PlayedAt, - WPM FinalWpm, - Accuracy FinalAccuracy, - TimeSpan Duration, - TextSample TargetText, - IReadOnlyList Telemetry -); - -public readonly record struct GameStatsSnapshot( +public readonly record struct TestSnapshot( WPM WPM, Accuracy Accuracy, CharacterStats Chars, TimeSpan ElapsedTime ) { - public static GameStatsSnapshot Create(CharacterStats chars, TimeSpan elapsed) + public static TestSnapshot Create(CharacterStats chars, TimeSpan elapsed) { int totalTyped = chars.Correct + chars.Corrections + chars.Incorrect; double accValue = totalTyped == 0 ? 100.0 : (double)chars.Correct / totalTyped * 100.0; double minutes = elapsed.TotalMinutes; double wpmValue = (minutes <= 0) ? 0 : (chars.Correct / 5.0) / minutes; - var snapshot = new GameStatsSnapshot( + var snapshot = new TestSnapshot( WPM.From(Math.Max(0, wpmValue)), Accuracy.From(Math.Clamp(accValue, 0, 100)), chars, @@ -37,7 +27,7 @@ public static GameStatsSnapshot Create(CharacterStats chars, TimeSpan elapsed) return snapshot; } - public static GameStatsSnapshot Empty => + public static TestSnapshot Empty => new((WPM)0, (Accuracy)100, new CharacterStats(0, 0, 0), TimeSpan.Zero); } diff --git a/src/Typical.Core/TypingSession.cs b/src/Typical.Core/TypingTest.cs similarity index 86% rename from src/Typical.Core/TypingSession.cs rename to src/Typical.Core/TypingTest.cs index fa1efe8..66b984c 100644 --- a/src/Typical.Core/TypingSession.cs +++ b/src/Typical.Core/TypingTest.cs @@ -10,28 +10,28 @@ namespace Typical.Core; -public class TypingSession +public class TypingTest { private readonly TypingBuffer _userInput = new(); private string[] _targetGraphemes = []; private readonly GameOptions _gameOptions; private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - public event EventHandler? OnSessionFinished; + private readonly ILogger _logger; + public event EventHandler? OnTestFinished; - public TypingSession( + public TypingTest( GameOptions gameOptions, - ILogger logger, + ILogger logger, TimeProvider timeProvider ) { _gameOptions = gameOptions; _timeProvider = timeProvider; - Stats = new GameStats(_timeProvider); + Stats = new Statistics.Statistics(_timeProvider); _logger = logger; } - internal GameStats Stats { get; private set; } + internal Statistics.Statistics Stats { get; private set; } public string TargetText { get; private set; } = string.Empty; public string UserInput => _userInput.ToString(); @@ -40,7 +40,7 @@ TimeProvider timeProvider public TextSample SampleNormalized { get; private set; } = TextSample.Empty; - public GameStatsSnapshot CreateSnapshot() => Stats.CreateSnapshot(); + public TestSnapshot CreateSnapshot() => Stats.CreateSnapshot(); public bool ProcessKeyPress(string input, bool isBackspace) { @@ -92,7 +92,7 @@ private void CheckEndCondition() IsOver = true; Stats.Stop(); var snapshot = Stats.CreateSnapshot(); - var result = new GameResult( + var result = new TestResult( DateTime.UtcNow, snapshot.WPM, snapshot.Accuracy, @@ -101,7 +101,7 @@ private void CheckEndCondition() Stats.Keystrokes ); - OnSessionFinished?.Invoke(this, result); + OnTestFinished?.Invoke(this, result); } } @@ -122,7 +122,7 @@ public void LoadText(TextSample sample) _userInput.Clear(); IsOver = false; - Stats = new GameStats(_timeProvider); + Stats = new Statistics.Statistics(_timeProvider); } internal KeystrokeType GetStatus(int index) diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index 968440f..d42a8c1 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -7,7 +7,7 @@ namespace Typical.Core.ViewModels; -public sealed partial class MainViewModel : ObservableObject, IRecipient +public sealed partial class MainViewModel : ObservableObject, IRecipient { private readonly INavigationService _navigationService; private readonly IDialogService _dialogService; @@ -42,7 +42,7 @@ IMessenger messenger _logger = logger; _messenger = messenger; - _messenger.Register(this, (r, m) => r.Receive(m)); + _messenger.Register(this, (r, m) => r.Receive(m)); } [RelayCommand] @@ -60,7 +60,7 @@ private void ShowAbout() _dialogService.ShowError("About", "Typical: A Terminal.Gui v2 MVVM Demo"); } - public void Receive(SessionCompletedMessage message) + public void Receive(TestCompletedMessage message) { _navigationService.NavigateTo(vm => vm.Initialize(message.Result)); } diff --git a/src/Typical.Core/ViewModels/ResultsViewModel.cs b/src/Typical.Core/ViewModels/ResultsViewModel.cs index 23e6377..5388d02 100644 --- a/src/Typical.Core/ViewModels/ResultsViewModel.cs +++ b/src/Typical.Core/ViewModels/ResultsViewModel.cs @@ -5,7 +5,7 @@ namespace Typical.Core.ViewModels; public class ResultsViewModel : ObservableObject { - public void Initialize(GameResult result) + public void Initialize(TestResult result) { // TODO: finish implementing display of results throw new NotImplementedException(); diff --git a/src/Typical.Core/ViewModels/SettingsViewModel.cs b/src/Typical.Core/ViewModels/SettingsViewModel.cs index 9fb4dbc..1bbf49f 100644 --- a/src/Typical.Core/ViewModels/SettingsViewModel.cs +++ b/src/Typical.Core/ViewModels/SettingsViewModel.cs @@ -33,7 +33,7 @@ IMessenger messenger [RelayCommand] private void QuoteMode() { - var message = new SessionResetMessage(new QuoteMode(QuoteLength.Medium)); + var message = new TestResetMessage(new QuoteMode(QuoteLength.Medium)); _messenger.Send(message); } diff --git a/src/Typical.Core/ViewModels/StatsViewModel.cs b/src/Typical.Core/ViewModels/StatsViewModel.cs index ef381e3..bf03391 100644 --- a/src/Typical.Core/ViewModels/StatsViewModel.cs +++ b/src/Typical.Core/ViewModels/StatsViewModel.cs @@ -5,20 +5,20 @@ namespace Typical.Core.ViewModels; -public partial class StatsViewModel : ObservableObject, IRecipient +public partial class StatsViewModel : ObservableObject, IRecipient { private readonly IMessenger _messenger; [ObservableProperty] - public partial GameStatsSnapshot Stats { get; set; } = GameStatsSnapshot.Empty; + public partial TestSnapshot Stats { get; set; } = TestSnapshot.Empty; public StatsViewModel(IMessenger messenger) { _messenger = messenger; - _messenger.Register(this, (r, m) => r.Receive(m)); + _messenger.Register(this, (r, m) => r.Receive(m)); } - public void Receive(GameStatsUpdatedMessage message) + public void Receive(StatisticsUpdatedMessage message) { Stats = message.State; } diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index 8635a8e..2a56e77 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -16,9 +16,9 @@ namespace Typical.Core.ViewModels; public partial class TypingViewModel : ObservableObject, INavigatableView, - IRecipient + IRecipient { - private readonly TypingSession _session; + private readonly TypingTest _Test; private readonly ITextProvider _textProvider; private readonly IStatsRepository _statsRepository; private readonly ILogger _logger; @@ -33,7 +33,7 @@ public partial class TypingViewModel [SetsRequiredMembers] public TypingViewModel( - TypingSession session, + TypingTest Test, ITextProvider textProvider, IStatsRepository statsRepository, INavigationService navigationService, @@ -41,8 +41,8 @@ public TypingViewModel( IMessenger messenger ) { - _session = session; - _session.OnSessionFinished += async (s, result) => await HandleSessionFinished(result); + _Test = Test; + _Test.OnTestFinished += async (s, result) => await HandleTestFinished(result); _textProvider = textProvider; _statsRepository = statsRepository; _logger = logger; @@ -52,10 +52,10 @@ IMessenger messenger _refreshTimer.AutoReset = true; _refreshTimer.Elapsed += OnRefreshTimerElapsed; - _messenger.Register(this, (r, m) => r.Receive(m)); + _messenger.Register(this, (r, m) => r.Receive(m)); } - public bool IsGameOver => _session.IsOver; + public bool IsGameOver => _Test.IsOver; /// /// Processes input received from the View. @@ -63,13 +63,13 @@ IMessenger messenger /// public async void ProcessInput(string c, bool isBackspace) { - if (_session.IsOver) + if (_Test.IsOver) { await InitializeAsync(); return; } - bool accepted = _session.ProcessKeyPress(c, isBackspace); + bool accepted = _Test.ProcessKeyPress(c, isBackspace); UpdateState(); } @@ -82,8 +82,8 @@ public async void ProcessInput(string c, bool isBackspace) /// private void UpdateState() { - var snapshot = _session.CreateSnapshot(); - _messenger.Send(new GameStatsUpdatedMessage(snapshot)); + var snapshot = _Test.CreateSnapshot(); + _messenger.Send(new StatisticsUpdatedMessage(snapshot)); } public void OnNavigatedTo() @@ -100,7 +100,7 @@ public void OnNavigatedFrom() private void OnRefreshTimerElapsed(object? sender, ElapsedEventArgs e) { - if (_session.IsOver) + if (_Test.IsOver) { return; } @@ -111,11 +111,11 @@ private void OnRefreshTimerElapsed(object? sender, ElapsedEventArgs e) public async Task InitializeAsync(TextSample? textSample = null) { Target = textSample ?? await _textProvider.GetQuoteAsync(); - _session.LoadText(Target); + _Test.LoadText(Target); UpdateState(); } - public async void Receive(SessionResetMessage message) + public async void Receive(TestResetMessage message) { TextSample textSample = message.Settings switch { @@ -130,10 +130,10 @@ public async void Receive(SessionResetMessage message) public KeystrokeType GetStatus(int globalIdx) { - return _session.GetStatus(globalIdx); + return _Test.GetStatus(globalIdx); } - private async Task HandleSessionFinished(GameResult result) + private async Task HandleTestFinished(TestResult result) { if (_isFinishing) return; @@ -142,7 +142,7 @@ private async Task HandleSessionFinished(GameResult result) try { await _statsRepository.SaveGameResultAsync(result); - _messenger.Send(new SessionCompletedMessage(result)); + _messenger.Send(new TestCompletedMessage(result)); } finally { diff --git a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs index 7c7b819..5af1028 100644 --- a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs @@ -6,7 +6,7 @@ namespace Typical.DataAccess.Sqlite; public class SimpleStatsRepository : IStatsRepository { - public Task SaveGameResultAsync(GameResult result) + public Task SaveGameResultAsync(TestResult result) { Debug.WriteLine("SimpleStatsRepository"); diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs new file mode 100644 index 0000000..7ef298c --- /dev/null +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -0,0 +1,22 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Options; +using Typical.Core.Data; +using Typical.Core.Statistics; + +namespace Typical.DataAccess.Sqlite; + +public class StatsRepository(IOptions options) : IStatsRepository +{ + public async Task SaveGameResultAsync(TestResult result) + { + await using var connection = await GetConnectionAsync(); + await using var command = connection.CreateCommand(); + } + + private async Task GetConnectionAsync() + { + var connection = new SqliteConnection(options.Value.GetConnectionString()); + await connection.OpenAsync(); + return connection; + } +} diff --git a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs index e3327ce..5757075 100644 --- a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs +++ b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs @@ -9,7 +9,7 @@ public class GameStatsTests public async Task CreateSnapshot_CalculatesAccurateWPM_BasedOnTime() { var fakeTime = new FakeTimeProvider(); - var stats = new GameStats(fakeTime); + var stats = new Typical.Core.Statistics.Statistics(fakeTime); stats.Start(); // Record 6 correct characters ("hello ") for (int i = 0; i < 6; i++) @@ -25,7 +25,7 @@ public async Task CreateSnapshot_CalculatesAccurateWPM_BasedOnTime() public async Task CreateSnapshot_CalculatesAccuracy_WithErrors() { var fakeTime = new FakeTimeProvider(); - var stats = new GameStats(fakeTime); + var stats = new Typical.Core.Statistics.Statistics(fakeTime); stats.Start(); for (int i = 0; i < 9; i++) stats.RecordKey("a", KeystrokeType.Correct); @@ -40,7 +40,7 @@ public async Task CreateSnapshot_CalculatesAccuracy_WithErrors() public async Task CreateSnapshot_HandlesZeroElapsed_PreventsDivideByZero() { var fakeTime = new FakeTimeProvider(); - var stats = new GameStats(fakeTime); + var stats = new Typical.Core.Statistics.Statistics(fakeTime); stats.Start(); stats.Stop(); var snapshot = stats.CreateSnapshot(); diff --git a/src/Typical.Tests/Core/Statistics/TypingSessionTests.cs b/src/Typical.Tests/Core/Statistics/TypingTestTests.cs similarity index 86% rename from src/Typical.Tests/Core/Statistics/TypingSessionTests.cs rename to src/Typical.Tests/Core/Statistics/TypingTestTests.cs index 6cac10f..78dd996 100644 --- a/src/Typical.Tests/Core/Statistics/TypingSessionTests.cs +++ b/src/Typical.Tests/Core/Statistics/TypingTestTests.cs @@ -12,19 +12,19 @@ namespace Typical.Tests.Core.Statistics; -public class TypingSessionTests +public class TypingTestTests { private readonly TimeProvider _timeProvider = new FakeTimeProvider(new DateTime(2025, 01, 01)); private readonly MockTextProvider _mockTextProvider; private readonly GameOptions _defaultOptions; private readonly GameOptions _strictOptions; - private readonly Microsoft.Extensions.Logging.ILogger _logger; - private readonly GameStats _stats; + private readonly Microsoft.Extensions.Logging.ILogger _logger; + private readonly Typical.Core.Statistics.Statistics _stats; private const int BOGUS_SEED = 999_999_001; private readonly Random _seed = new Random(BOGUS_SEED); private readonly DefaultLogger _testLogger; - public TypingSessionTests() + public TypingTestTests() { _testLogger = TestContext.Current!.GetDefaultLogger(); Bogus.Randomizer.Seed = _seed; @@ -32,8 +32,8 @@ public TypingSessionTests() _mockTextProvider = new MockTextProvider(); _defaultOptions = new GameOptions(); _strictOptions = new GameOptions { ForbidIncorrectEntries = true }; - _logger = NullLogger.Instance; - _stats = new GameStats(_timeProvider); + _logger = NullLogger.Instance; + _stats = new Typical.Core.Statistics.Statistics(_timeProvider); } // --- StartNewGame Tests --- @@ -44,7 +44,7 @@ public async Task StartNewGame_Always_LoadsTextFromProvider() // Arrange var expectedText = "This is a test."; _mockTextProvider.SetText(expectedText); - var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); // Act sut.LoadText(await _mockTextProvider.GetWordsAsync()); @@ -58,7 +58,7 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() { // Arrange // 1. Initial Setup - var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); string firstText = "some text"; // 2. Load the first sut @@ -90,7 +90,7 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() { // Arrange - var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); @@ -109,7 +109,7 @@ public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() { // Arrange - var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); await Assert.That(sut.UserInput).IsEmpty(); @@ -124,7 +124,7 @@ public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() { // Arrange - var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); sut.LoadText(new TextSample() { Text = "hi", Source = "test" }); // Act @@ -143,7 +143,7 @@ public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() { // Arrange - var sut = new TypingSession(_strictOptions, _logger, _timeProvider); // _gameOptions.ForbidIncorrectEntries = true + var sut = new TypingTest(_strictOptions, _logger, _timeProvider); // _gameOptions.ForbidIncorrectEntries = true sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -158,7 +158,7 @@ public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendCharacter() { // Arrange - var sut = new TypingSession(_strictOptions, _logger, _timeProvider); + var sut = new TypingTest(_strictOptions, _logger, _timeProvider); sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -173,7 +173,7 @@ public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendChara public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter() { // Arrange - var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); // _gameOptions.ForbidIncorrectEntries = false + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); // _gameOptions.ForbidIncorrectEntries = false sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -191,7 +191,7 @@ public async Task ProcessKeyPress_WithRandomText_MatchesState() var text = lorem.Sentence(); - var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); sut.LoadText(new TextSample() { Text = text, Source = "Bogus" }); var enumerator = StringInfo.GetTextElementEnumerator(text); @@ -211,7 +211,7 @@ public async Task ProcessKeyPress_WithEmoji_HandlesGraphemesCorrectly() { // Arrange: emoji with modifier (👍🏽) var emojiText = "👍🏽"; - var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); sut.LoadText(new TextSample { Text = emojiText, Source = "test" }); // Act: process the emoji as a single grapheme @@ -229,7 +229,7 @@ public async Task Engine_ShouldHandleWordsInInternationalLocales(string locale) { var faker = new Faker(locale); var internationalText = faker.Random.Words(10); - var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); sut.LoadText(new TextSample() { Text = internationalText, Source = locale }); // Act @@ -252,7 +252,7 @@ public async Task Engine_ShouldHandleWordsInInternationalLocales(string locale) public async Task Engine_ShouldHandleSentencesInInternationalLocales(string locale) { var faker = new Faker(locale); - var sut = new TypingSession(_defaultOptions, _logger, _timeProvider); + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); var textSample = new TextSample() { Text = faker.Lorem.Sentence(), Source = locale }; sut.LoadText(textSample); diff --git a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs index dfaa22d..354b5a5 100644 --- a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs @@ -17,13 +17,13 @@ public async Task Receive_GameStatsUpdatedMessage_UpdatesProperties() var messenger = provider.GetRequiredService(); var sut = new StatsViewModel(messenger); - var fakeSnapshot = new GameStatsSnapshot( + var fakeSnapshot = new TestSnapshot( WPM: (WPM)65.8, Accuracy: (Accuracy)98.5, Chars: new CharacterStats(10, 1, 2), ElapsedTime: TimeSpan.FromSeconds(30) ); - var msg = new GameStatsUpdatedMessage(fakeSnapshot); + var msg = new StatisticsUpdatedMessage(fakeSnapshot); messenger.Send(msg); diff --git a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs index 82aa7ec..fd9cdf2 100644 --- a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs @@ -26,9 +26,9 @@ public async Task InitializeAsync_LoadsQuote_FromTextProvider() { var mockTextProvider = new MockTextProvider(); mockTextProvider.SetText("Hello world!"); - var engine = new TypingSession( + var engine = new TypingTest( GameOptions.Default, - NullLogger.Instance, + NullLogger.Instance, TimeProvider.System ); @@ -49,9 +49,9 @@ public async Task ProcessInput_UpdatesEngine_AndBroadcastsMessage() { var mockTextProvider = new MockTextProvider(); mockTextProvider.SetText("a"); - var engine = new TypingSession( + var engine = new TypingTest( GameOptions.Default, - NullLogger.Instance, + NullLogger.Instance, TimeProvider.System ); var vm = new TypingViewModel( @@ -73,9 +73,9 @@ public async Task Receive_GameResetMessage_ReloadsText_BasedOnSettings() { var mockTextProvider = new MockTextProvider(); mockTextProvider.SetText("reset quote"); - var engine = new TypingSession( + var engine = new TypingTest( GameOptions.Default, - NullLogger.Instance, + NullLogger.Instance, TimeProvider.System ); var mockMessenger = IMessenger.Imposter(); @@ -88,7 +88,7 @@ public async Task Receive_GameResetMessage_ReloadsText_BasedOnSettings() NullLogger.Instance, mockMessenger.Instance() ); - var msg = new SessionResetMessage(new QuoteMode(QuoteLength.Short)); + var msg = new TestResetMessage(new QuoteMode(QuoteLength.Short)); vm.Receive(msg); await Task.Delay(10); // Allow async to complete await Assert.That(vm.Target.Text).IsEqualTo("reset quote"); diff --git a/src/Typical.Tests/UI/NavigationServiceTests.cs b/src/Typical.Tests/UI/NavigationServiceTests.cs index fb78a09..8c9b6e1 100644 --- a/src/Typical.Tests/UI/NavigationServiceTests.cs +++ b/src/Typical.Tests/UI/NavigationServiceTests.cs @@ -38,9 +38,9 @@ public void Setup() services.AddSingleton(_textProviderMock.Instance()); services.AddSingleton(_statsRepoMock.Instance()); - services.AddSingleton(sp => new TypingSession( + services.AddSingleton(sp => new TypingTest( new GameOptions(), - NullLogger.Instance, + NullLogger.Instance, TimeProvider.System )); diff --git a/todo.md b/todo.md index 548f46d..673c451 100644 --- a/todo.md +++ b/todo.md @@ -38,13 +38,13 @@ - **Action:** - Change the parameterless constructor to accept `TimeProvider timeProvider`. - Assign it to the `_timeProvider` readonly field. -- **Target File:** `src/Typical.Core/TypingSession.cs` +- **Target File:** `src/Typical.Core/TypingTest.cs` - **Action:** - Update the constructor to accept `TimeProvider timeProvider`. - Update the instantiation: `Stats = new GameStats(timeProvider);`. -- **Target File:** `src/Typical.Tests/TypingSessionTests.cs` +- **Target File:** `src/Typical.Tests/TypingTestTests.cs` - **Action:** - - Fix compiler errors by passing `TimeProvider.System` (or a `FakeTimeProvider`) to `TypingSession` instantiations in the test setups. + - Fix compiler errors by passing `TimeProvider.System` (or a `FakeTimeProvider`) to `TypingTest` instantiations in the test setups. ### Task 1.2: Abstract `IMessenger` in ViewModels @@ -141,7 +141,7 @@ ### Task 5.1: Grapheme Cluster Boundary Tests -- **Target File:** `src/Typical.Tests/TypingSessionTests.cs` +- **Target File:** `src/Typical.Tests/TypingTestTests.cs` - **Action:** Add a test `ProcessKeyPress_WithEmoji_HandlesGraphemesCorrectly`: - **Setup:** Load text containing an emoji with a modifier (e.g., `👍🏽`). - **Action:** Call `ProcessKeyPress("👍🏽", false)`. From 18cfd7414ad86d91e0ffd300ca45f1934907eb82 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Sat, 23 May 2026 03:03:58 +0100 Subject: [PATCH 25/58] wip --- .../202605230236_CreateQuotesTable.sql | 10 ++++++ .../202605230238_AddStatsTables.sql | 2 ++ .../Migrations/Add-MigrationFile.ps1 | 3 ++ .../Migrations/DatabaseMigrator.cs | 9 +++++ .../Script_00100_CreateQuotesTable.cs | 33 ------------------- ... Script_202605230237_SeedInitialQuotes.cs} | 4 +-- .../Typical.DataAccess.csproj | 3 ++ src/Typical.DataAccess/TypicalDbOptions.cs | 2 ++ 8 files changed, 31 insertions(+), 35 deletions(-) create mode 100644 src/Typical.DataAccess/Migrations/202605230236_CreateQuotesTable.sql create mode 100644 src/Typical.DataAccess/Migrations/202605230238_AddStatsTables.sql create mode 100644 src/Typical.DataAccess/Migrations/Add-MigrationFile.ps1 delete mode 100644 src/Typical.DataAccess/Migrations/Script_00100_CreateQuotesTable.cs rename src/Typical.DataAccess/Migrations/{Script_00200_SeedInitialQuotes.cs => Script_202605230237_SeedInitialQuotes.cs} (94%) diff --git a/src/Typical.DataAccess/Migrations/202605230236_CreateQuotesTable.sql b/src/Typical.DataAccess/Migrations/202605230236_CreateQuotesTable.sql new file mode 100644 index 0000000..4ba225d --- /dev/null +++ b/src/Typical.DataAccess/Migrations/202605230236_CreateQuotesTable.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS Quotes ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Text TEXT NOT NULL, + Author TEXT NULL, + Tags TEXT NULL, + CharCount INTEGER GENERATED ALWAYS AS (length(Text)) VIRTUAL, + WordCount INTEGER GENERATED ALWAYS AS (length(Text) / 5.0) VIRTUAL +); + +CREATE INDEX IF NOT EXISTS IX_Quotes_Id ON Quotes(Id); diff --git a/src/Typical.DataAccess/Migrations/202605230238_AddStatsTables.sql b/src/Typical.DataAccess/Migrations/202605230238_AddStatsTables.sql new file mode 100644 index 0000000..67a7bdd --- /dev/null +++ b/src/Typical.DataAccess/Migrations/202605230238_AddStatsTables.sql @@ -0,0 +1,2 @@ +-- Add stats tables here. +-- This migration is intentionally empty until stats database schema is added. diff --git a/src/Typical.DataAccess/Migrations/Add-MigrationFile.ps1 b/src/Typical.DataAccess/Migrations/Add-MigrationFile.ps1 new file mode 100644 index 0000000..27cc422 --- /dev/null +++ b/src/Typical.DataAccess/Migrations/Add-MigrationFile.ps1 @@ -0,0 +1,3 @@ +$dateString = Get-Date -Format 'yyyyMMddHHmm' + +$file = 'Script_' + $dateString + '_description.sql' diff --git a/src/Typical.DataAccess/Migrations/DatabaseMigrator.cs b/src/Typical.DataAccess/Migrations/DatabaseMigrator.cs index 3d3c33a..485d73b 100644 --- a/src/Typical.DataAccess/Migrations/DatabaseMigrator.cs +++ b/src/Typical.DataAccess/Migrations/DatabaseMigrator.cs @@ -1,3 +1,4 @@ +using System.IO; using DbUp; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -13,12 +14,20 @@ public Task EnsureDatabaseUpdated() { logger.LogInformation("Opening Db"); var connectionString = options.Value.GetConnectionString(); + var scriptsDirectory = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, options.Value.ScriptsDirectory) + ); logger.LogInformation("ConnectionString: {ConnectionString}", connectionString); + logger.LogInformation( + "Loading DbUp scripts from filesystem: {ScriptsDirectory}", + scriptsDirectory + ); var upgrader = DeployChanges .To.SqliteDatabase(connectionString) .WithGeneratedScripts() + .WithScriptsFromFileSystem(scriptsDirectory) .LogTo(logger) .LogScriptOutput() .Build(); diff --git a/src/Typical.DataAccess/Migrations/Script_00100_CreateQuotesTable.cs b/src/Typical.DataAccess/Migrations/Script_00100_CreateQuotesTable.cs deleted file mode 100644 index cd14a04..0000000 --- a/src/Typical.DataAccess/Migrations/Script_00100_CreateQuotesTable.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Data; -using DbUp; -using DbUp.Engine; - -namespace Typical.DataAccess.Sqlite; - -[DbUpScript(ScriptType = DbUpScriptType.RunOnce, RunGroupOrder = 0)] -public class Script_00100_CreateQuotesTable : IScript -{ - public string ProvideScript(Func dbCommandFactory) - { - using (var command = dbCommandFactory()) - { - command.CommandText = """ -CREATE TABLE IF NOT EXISTS Quotes ( - Id INTEGER PRIMARY KEY AUTOINCREMENT, - Text TEXT NOT NULL, - Author TEXT NULL, - Tags TEXT NULL, - CharCount INTEGER GENERATED ALWAYS AS (length(Text)) VIRTUAL, - WordCount INTEGER GENERATED ALWAYS AS (length(Text) / 5.0) VIRTUAL -); - -CREATE INDEX IF NOT EXISTS IX_Quotes_Id ON Quotes(Id); -"""; - - command.ExecuteNonQuery(); - } - - // Return a name for the journal - return ""; - } -} diff --git a/src/Typical.DataAccess/Migrations/Script_00200_SeedInitialQuotes.cs b/src/Typical.DataAccess/Migrations/Script_202605230237_SeedInitialQuotes.cs similarity index 94% rename from src/Typical.DataAccess/Migrations/Script_00200_SeedInitialQuotes.cs rename to src/Typical.DataAccess/Migrations/Script_202605230237_SeedInitialQuotes.cs index 1dfbf72..5593824 100644 --- a/src/Typical.DataAccess/Migrations/Script_00200_SeedInitialQuotes.cs +++ b/src/Typical.DataAccess/Migrations/Script_202605230237_SeedInitialQuotes.cs @@ -14,7 +14,7 @@ internal partial class SeedContext : JsonSerializerContext; internal record QuoteSeed(string Text, string Author, List? Tags); [DbUpScript(ScriptType = DbUpScriptType.RunOnce, RunGroupOrder = 0)] -public class Script_00200_SeedInitialQuotes : IScript +public class Script_202605230237_SeedInitialQuotes : IScript { public string ProvideScript(Func dbCommandFactory) { @@ -24,7 +24,7 @@ public string ProvideScript(Func dbCommandFactory) if ((long)(cmd.ExecuteScalar() ?? 0) == 1) return ""; - var assembly = typeof(Script_00200_SeedInitialQuotes).Assembly; + var assembly = typeof(Script_202605230237_SeedInitialQuotes).Assembly; var path = AppContext.BaseDirectory; using var stream = File.OpenRead(Path.Combine(path, "Migrations", "quotes.json")); if (stream is null) diff --git a/src/Typical.DataAccess/Typical.DataAccess.csproj b/src/Typical.DataAccess/Typical.DataAccess.csproj index 077b1d4..5c4b599 100644 --- a/src/Typical.DataAccess/Typical.DataAccess.csproj +++ b/src/Typical.DataAccess/Typical.DataAccess.csproj @@ -26,5 +26,8 @@ PreserveNewest + + PreserveNewest + diff --git a/src/Typical.DataAccess/TypicalDbOptions.cs b/src/Typical.DataAccess/TypicalDbOptions.cs index 13b893d..a3d57dc 100644 --- a/src/Typical.DataAccess/TypicalDbOptions.cs +++ b/src/Typical.DataAccess/TypicalDbOptions.cs @@ -8,6 +8,8 @@ public class TypicalDbOptions public string DataDirectory { get; set; } = Path.GetTempPath(); + public string ScriptsDirectory { get; set; } = "Migrations"; + public string GetDatabasePath() => Path.Combine(DataDirectory, DatabaseFileName); public string GetConnectionString() => $"Data Source={GetDatabasePath()}"; From 4cf646c4bf9f87767b9557277ffcffc6f76120ae Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Sat, 23 May 2026 03:22:52 +0100 Subject: [PATCH 26/58] wip --- src/Typical.Core/Statistics/TestResult.cs | 2 +- .../{Migrations => }/Add-MigrationFile.ps1 | 0 .../{Migrations => }/DatabaseMigrator.cs | 0 .../202605230238_AddStatsTables.sql | 2 - .../Migrations/202605230238_AddTestTables.sql | 32 +++++ .../Sqlite/StatsRepository.cs | 118 +++++++++++++++++- 6 files changed, 149 insertions(+), 5 deletions(-) rename src/Typical.DataAccess/{Migrations => }/Add-MigrationFile.ps1 (100%) rename src/Typical.DataAccess/{Migrations => }/DatabaseMigrator.cs (100%) delete mode 100644 src/Typical.DataAccess/Migrations/202605230238_AddStatsTables.sql create mode 100644 src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql diff --git a/src/Typical.Core/Statistics/TestResult.cs b/src/Typical.Core/Statistics/TestResult.cs index aeacc27..a9895ee 100644 --- a/src/Typical.Core/Statistics/TestResult.cs +++ b/src/Typical.Core/Statistics/TestResult.cs @@ -7,6 +7,6 @@ public readonly record struct TestResult( WPM FinalWpm, Accuracy FinalAccuracy, TimeSpan Duration, - TextSample TargetText, + TextSample Target, IReadOnlyList Telemetry ); diff --git a/src/Typical.DataAccess/Migrations/Add-MigrationFile.ps1 b/src/Typical.DataAccess/Add-MigrationFile.ps1 similarity index 100% rename from src/Typical.DataAccess/Migrations/Add-MigrationFile.ps1 rename to src/Typical.DataAccess/Add-MigrationFile.ps1 diff --git a/src/Typical.DataAccess/Migrations/DatabaseMigrator.cs b/src/Typical.DataAccess/DatabaseMigrator.cs similarity index 100% rename from src/Typical.DataAccess/Migrations/DatabaseMigrator.cs rename to src/Typical.DataAccess/DatabaseMigrator.cs diff --git a/src/Typical.DataAccess/Migrations/202605230238_AddStatsTables.sql b/src/Typical.DataAccess/Migrations/202605230238_AddStatsTables.sql deleted file mode 100644 index 67a7bdd..0000000 --- a/src/Typical.DataAccess/Migrations/202605230238_AddStatsTables.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add stats tables here. --- This migration is intentionally empty until stats database schema is added. diff --git a/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql new file mode 100644 index 0000000..d0e943f --- /dev/null +++ b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql @@ -0,0 +1,32 @@ + +CREATE TABLE Tests ( + Id INTEGER PRIMARY KEY, + CreatedAt INTEGER NOT NULL, + Wpm REAL NOT NULL, + RawWpm REAL NOT NULL, + Accuracy REAL NOT NULL, + DurationMs INTEGER NOT NULL, + TargetText TEXT NOT NULL, + Source TEXT +); + +CREATE TABLE KeystrokeTelemetry ( + TestId INTEGER NOT NULL, + OffsetMs INTEGER NOT NULL, + GraphemeIndex INTEGER NOT NULL, + ActualText TEXT NOT NULL, + KeystrokeType INTEGER NOT NULL, + + PRIMARY KEY (TestId, OffsetMs), + FOREIGN KEY (TestId) REFERENCES Tests(Id) ON DELETE CASCADE +) WITHOUT ROWID; + +CREATE TABLE TestSnapshots ( + TestId INTEGER NOT NULL, + OffsetMs INTEGER NOT NULL, + Wpm REAL NOT NULL, + Accuracy REAL NOT NULL, + + PRIMARY KEY (TestId, OffsetMs), + FOREIGN KEY (TestId) REFERENCES Tests(Id) ON DELETE CASCADE +) WITHOUT ROWID; diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index 7ef298c..e51a042 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -1,6 +1,6 @@ +using System.Text; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; -using Typical.Core.Data; using Typical.Core.Statistics; namespace Typical.DataAccess.Sqlite; @@ -10,7 +10,121 @@ public class StatsRepository(IOptions options) : IStatsReposit public async Task SaveGameResultAsync(TestResult result) { await using var connection = await GetConnectionAsync(); - await using var command = connection.CreateCommand(); + await using var transaction = connection.BeginTransaction(); + + try + { + // 1. Insert the Test Header + long testId = await InsertTestHeaderAsync(connection, transaction, result); + + // 2. Insert Keystroke Telemetry (Bulk) + await InsertTelemetryAsync(connection, transaction, testId, result); + + // 3. Insert Snapshots (Bulk) + await InsertSnapshotsAsync(connection, transaction, testId, result); + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + private async Task InsertTestHeaderAsync( + SqliteConnection conn, + SqliteTransaction trans, + TestResult result + ) + { + const string sql = """ + INSERT INTO Tests (CreatedAt, Wpm, RawWpm, Accuracy, DurationMs, TargetText, Source) + VALUES (@CreatedAt, @Wpm, @RawWpm, @Accuracy, @DurationMs, @TargetText, @Source); + SELECT last_insert_rowid(); + """; + + await using var cmd = new SqliteCommand(sql, conn, trans); + + // Convert DateTime to Unix Milliseconds (Standard for SQLite INTEGER) + cmd.Parameters.AddWithValue( + "@CreatedAt", + new DateTimeOffset(result.PlayedAt).ToUnixTimeMilliseconds() + ); + cmd.Parameters.AddWithValue("@Wpm", result.FinalWpm.Value); + cmd.Parameters.AddWithValue("@RawWpm", result.RawWpm.Value); + cmd.Parameters.AddWithValue("@Accuracy", result.FinalAccuracy.Value); + cmd.Parameters.AddWithValue("@DurationMs", (long)result.Duration.TotalMilliseconds); + cmd.Parameters.AddWithValue("@TargetText", result.Target); + cmd.Parameters.AddWithValue("@Source", result.Target.Source ?? (object)DBNull.Value); + + return (long)(await cmd.ExecuteScalarAsync() ?? 0L); + } + + private async Task InsertTelemetryAsync( + SqliteConnection conn, + SqliteTransaction trans, + long testId, + TestResult result + ) + { + const string sql = """ + INSERT INTO KeystrokeTelemetry (TestId, OffsetMs, GraphemeIndex, ActualText, KeystrokeType) + VALUES (@TestId, @OffsetMs, @Index, @Actual, @Type); + """; + + await using var cmd = new SqliteCommand(sql, conn, trans); + var pTestId = cmd.Parameters.Add("@TestId", SqliteType.Integer); + var pOffset = cmd.Parameters.Add("@OffsetMs", SqliteType.Integer); + var pIndex = cmd.Parameters.Add("@Index", SqliteType.Integer); + var pActual = cmd.Parameters.Add("@Actual", SqliteType.Text); + var pType = cmd.Parameters.Add("@Type", SqliteType.Integer); + + pTestId.Value = testId; + + // Note: result.Telemetry is your List + foreach (var log in result.Telemetry) + { + pOffset.Value = log.Timestamp; // Relative offset from game start + pIndex.Value = log.Index; + pActual.Value = log.Value; + pType.Value = (int)log.Type; + + await cmd.ExecuteNonQueryAsync(); + } + } + + private async Task InsertSnapshotsAsync( + SqliteConnection conn, + SqliteTransaction trans, + long testId, + TestResult result + ) + { + if (result.Snapshots.Count == 0) + return; + + const string sql = """ + INSERT INTO TestSnapshots (TestId, OffsetMs, Wpm, Accuracy) + VALUES (@TestId, @OffsetMs, @Wpm, @Acc); + """; + + await using var cmd = new SqliteCommand(sql, conn, trans); + var pTestId = cmd.Parameters.Add("@TestId", SqliteType.Integer); + var pOffset = cmd.Parameters.Add("@OffsetMs", SqliteType.Integer); + var pWpm = cmd.Parameters.Add("@Wpm", SqliteType.Real); + var pAcc = cmd.Parameters.Add("@Acc", SqliteType.Real); + + pTestId.Value = testId; + + foreach (var snap in result.Snapshots) + { + pOffset.Value = (long)snap.ElapsedTime.TotalMilliseconds; + pWpm.Value = snap.WPM.Value; + pAcc.Value = snap.Accuracy.Value; + + await cmd.ExecuteNonQueryAsync(); + } } private async Task GetConnectionAsync() From 3fd9affbca06f63a206e9602280e25d89c86b93a Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 27 May 2026 07:36:04 +0100 Subject: [PATCH 27/58] wip: telemetry builder --- src/Typical.Core/Statistics/KeystrokeLog.cs | 2 +- .../Statistics/TelemetryBuilder.cs | 47 +++++++++++++++++++ src/Typical.Core/Statistics/TestFactory.cs | 23 +++++++++ .../Statistics/KeystrokeCollectionTests.cs | 2 +- .../Core/Statistics/TypingTestTests.cs | 2 +- 5 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/Typical.Core/Statistics/TelemetryBuilder.cs create mode 100644 src/Typical.Core/Statistics/TestFactory.cs diff --git a/src/Typical.Core/Statistics/KeystrokeLog.cs b/src/Typical.Core/Statistics/KeystrokeLog.cs index 0649153..e36bae2 100644 --- a/src/Typical.Core/Statistics/KeystrokeLog.cs +++ b/src/Typical.Core/Statistics/KeystrokeLog.cs @@ -1,3 +1,3 @@ namespace Typical.Core.Statistics; -public record struct KeystrokeLog(string Grapheme, KeystrokeType Type, long Timestamp); +public record struct KeystrokeLog(string Value, KeystrokeType Type, long Timestamp, long OffsetMs); diff --git a/src/Typical.Core/Statistics/TelemetryBuilder.cs b/src/Typical.Core/Statistics/TelemetryBuilder.cs new file mode 100644 index 0000000..2c1fb66 --- /dev/null +++ b/src/Typical.Core/Statistics/TelemetryBuilder.cs @@ -0,0 +1,47 @@ +using System.Globalization; + +namespace Typical.Core.Statistics; + +public class TelemetryBuilder +{ + private readonly List _logs = new(); + private int _currentIndex = 0; + private long _currentOffset = 0; + + public TelemetryBuilder Type(string text, int delayMs = 100) + { + // Use StringInfo to iterate by Grapheme, not by Char! + var enumerator = StringInfo.GetTextElementEnumerator(text); + while (enumerator.MoveNext()) + { + string grapheme = enumerator.GetTextElement(); + + _logs.Add( + new KeystrokeLog( + Value: grapheme, + Type: KeystrokeType.Correct, + Timestamp: 0, + OffsetMs: _currentOffset += delayMs, + Index: _currentIndex++ + ) + ); + } + return this; + } + + public TelemetryBuilder Error(string actualGrapheme, int delayMs = 100) + { + _logs.Add( + new KeystrokeLog( + Value: actualGrapheme, + Type: KeystrokeType.Incorrect, + Timestamp: 0, + OffsetMs: _currentOffset += delayMs, + Index: _currentIndex // Pointer doesn't move on error + ) + ); + return this; + } + + public List Build() => _logs; +} diff --git a/src/Typical.Core/Statistics/TestFactory.cs b/src/Typical.Core/Statistics/TestFactory.cs new file mode 100644 index 0000000..28b794a --- /dev/null +++ b/src/Typical.Core/Statistics/TestFactory.cs @@ -0,0 +1,23 @@ +using Typical.Core.Text; + +namespace Typical.Core.Statistics; + +public static class TestFactory +{ + public static TestResult CreateResult( + double wpm = 60, + double accuracy = 100, + TextSample? target = null, + List? telemetry = null + ) + { + return new TestResult( + PlayedAt: DateTime.UtcNow, + FinalWpm: WPM.From(wpm), + FinalAccuracy: Accuracy.From(accuracy), + Duration: TimeSpan.FromSeconds(10), + Target: target ?? TextSample.Empty, + Telemetry: telemetry ?? new List() + ); + } +} diff --git a/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs b/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs index 4684817..0e338fc 100644 --- a/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs +++ b/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs @@ -72,7 +72,7 @@ public async Task GetLog_ReturnsReadOnlyList() kc.Add("a", KeystrokeType.Correct, 1); var log = kc.GetLog(); await Assert.That(log.Count).IsEqualTo(1); - await Assert.That(log[0].Grapheme).IsEqualTo("a"); + await Assert.That(log[0].Value).IsEqualTo("a"); await Assert.That(log[0].Type).IsEqualTo(KeystrokeType.Correct); await Assert.That(log[0].Timestamp).IsEqualTo(1); } diff --git a/src/Typical.Tests/Core/Statistics/TypingTestTests.cs b/src/Typical.Tests/Core/Statistics/TypingTestTests.cs index 78dd996..5b0362a 100644 --- a/src/Typical.Tests/Core/Statistics/TypingTestTests.cs +++ b/src/Typical.Tests/Core/Statistics/TypingTestTests.cs @@ -219,7 +219,7 @@ public async Task ProcessKeyPress_WithEmoji_HandlesGraphemesCorrectly() // Assert: should count as exactly 1 correct keystroke await Assert.That(sut.Stats.Keystrokes.Count).IsEqualTo(1); - await Assert.That(sut.Stats.Keystrokes[0].Grapheme).IsEqualTo("👍🏽"); + await Assert.That(sut.Stats.Keystrokes[0].Value).IsEqualTo("👍🏽"); await Assert.That(sut.Stats.Keystrokes[0].Type).IsEqualTo(KeystrokeType.Correct); } From 0dd3433ac0551c4006b232ecb2978e6aa1e00d97 Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Wed, 27 May 2026 14:08:19 +0100 Subject: [PATCH 28/58] wip Telemetry tracking --- .../Statistics/KeystrokeCollection.cs | 34 +++++++-- src/Typical.Core/Statistics/KeystrokeLog.cs | 8 ++- src/Typical.Core/Statistics/Statistics.cs | 27 ++++++-- .../Statistics/TelemetryBuilder.cs | 47 ------------- src/Typical.Core/Statistics/TestResult.cs | 4 +- src/Typical.Core/TypingTest.cs | 18 ++--- .../Sqlite/StatsRepository.cs | 4 ++ .../Core/Statistics/GameStatsTests.cs | 19 ++--- .../Statistics/KeystrokeCollectionTests.cs | 69 +++++++++---------- .../Core/Statistics/TelemetryBuilder.cs | 68 ++++++++++++++++++ .../Core}/Statistics/TestFactory.cs | 0 11 files changed, 182 insertions(+), 116 deletions(-) delete mode 100644 src/Typical.Core/Statistics/TelemetryBuilder.cs create mode 100644 src/Typical.Tests/Core/Statistics/TelemetryBuilder.cs rename src/{Typical.Core => Typical.Tests/Core}/Statistics/TestFactory.cs (100%) diff --git a/src/Typical.Core/Statistics/KeystrokeCollection.cs b/src/Typical.Core/Statistics/KeystrokeCollection.cs index 16beb4a..1b4d851 100644 --- a/src/Typical.Core/Statistics/KeystrokeCollection.cs +++ b/src/Typical.Core/Statistics/KeystrokeCollection.cs @@ -5,15 +5,34 @@ namespace Typical.Core.Statistics; public class KeystrokeCollection { private readonly List _logs = new(); + private long? _firstTimestamp; public int CorrectCount { get; private set; } - public int TotalPhysicalKeystrokes => _logs.Count; + public int TotalPhysical => _logs.Count; public int ErrorCount { get; private set; } public int CorrectionCount { get; private set; } - public void Add(string actual, KeystrokeType type, long timestamp) + /// The grapheme typed. + /// The type of keystroke. + /// Raw timestamp from TimeProvider. + /// The current grapheme index in the target text. + public void Add(string actual, KeystrokeType type, long timestamp, int index) { - var log = new KeystrokeLog(actual, type, timestamp); + // Set the baseline for OffsetMs on the very first keypress + _firstTimestamp ??= timestamp; + + // Calculate offset in milliseconds (for SQLite efficiency) + // We use a helper or manual math here depending on your TimeProvider resolution + long offsetMs = (timestamp - _firstTimestamp.Value) / TimeSpan.TicksPerMillisecond; + + var log = new KeystrokeLog( + Value: actual, + Type: type, + Timestamp: timestamp, + OffsetMs: offsetMs, + Index: index + ); + _logs.Add(log); switch (type) @@ -35,12 +54,13 @@ public void Add(string actual, KeystrokeType type, long timestamp) internal void Clear() { _logs.Clear(); + _firstTimestamp = null; + CorrectCount = 0; + ErrorCount = 0; + CorrectionCount = 0; } - internal IReadOnlyList GetLog() - { - return _logs.AsReadOnly(); - } + internal IReadOnlyList GetLog() => _logs.AsReadOnly(); [Conditional("DEBUG")] private void LogDebug(KeystrokeLog log) => Debug.WriteLine(log); diff --git a/src/Typical.Core/Statistics/KeystrokeLog.cs b/src/Typical.Core/Statistics/KeystrokeLog.cs index e36bae2..7d0f506 100644 --- a/src/Typical.Core/Statistics/KeystrokeLog.cs +++ b/src/Typical.Core/Statistics/KeystrokeLog.cs @@ -1,3 +1,9 @@ namespace Typical.Core.Statistics; -public record struct KeystrokeLog(string Value, KeystrokeType Type, long Timestamp, long OffsetMs); +public record struct KeystrokeLog( + string Value, + KeystrokeType Type, + long Timestamp, + long OffsetMs, + int Index +); diff --git a/src/Typical.Core/Statistics/Statistics.cs b/src/Typical.Core/Statistics/Statistics.cs index adba405..76e0d74 100644 --- a/src/Typical.Core/Statistics/Statistics.cs +++ b/src/Typical.Core/Statistics/Statistics.cs @@ -1,3 +1,5 @@ +using Typical.Core.Text; + namespace Typical.Core.Statistics; public class Statistics @@ -25,17 +27,17 @@ public Statistics(TimeProvider timeProvider) : TimeSpan.Zero; public bool IsRunning => _startTimestamp.HasValue && !_endTimestamp.HasValue; - internal void RecordKey(string grapheme, KeystrokeType type) + internal void RecordKey(string grapheme, KeystrokeType type, int currentIndex) { if (!IsRunning) Start(); - _keystrokes.Add(grapheme, type, _timeProvider.GetTimestamp()); + _keystrokes.Add(grapheme, type, _timeProvider.GetTimestamp(), currentIndex); } - internal void RecordBackspace() + internal void RecordBackspace(int currentIndex) { - _keystrokes.Add("\b", KeystrokeType.Correction, _timeProvider.GetTimestamp()); + _keystrokes.Add("\b", KeystrokeType.Correction, _timeProvider.GetTimestamp(), currentIndex); } internal void Start() @@ -67,4 +69,21 @@ public TestSnapshot CreateSnapshot() return snapshot; } + + internal TestResult GetFinalResult(TextSample targetSample) + { + var finalSnapshot = CreateSnapshot(); + double minutes = ElapsedTime.Minutes; + double rawWpm = (minutes <= 0) ? 0 : (_keystrokes.TotalPhysical / 5.0) / minutes; + return new TestResult( + PlayedAt: DateTime.UtcNow, + FinalWpm: finalSnapshot.WPM, + RawWpm: WPM.From(rawWpm), + FinalAccuracy: finalSnapshot.Accuracy, + Duration: finalSnapshot.ElapsedTime, + Target: targetSample, + Telemetry: Keystrokes.ToList(), + Snapshots: Snapshots.ToList() + ); + } } diff --git a/src/Typical.Core/Statistics/TelemetryBuilder.cs b/src/Typical.Core/Statistics/TelemetryBuilder.cs deleted file mode 100644 index 2c1fb66..0000000 --- a/src/Typical.Core/Statistics/TelemetryBuilder.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Globalization; - -namespace Typical.Core.Statistics; - -public class TelemetryBuilder -{ - private readonly List _logs = new(); - private int _currentIndex = 0; - private long _currentOffset = 0; - - public TelemetryBuilder Type(string text, int delayMs = 100) - { - // Use StringInfo to iterate by Grapheme, not by Char! - var enumerator = StringInfo.GetTextElementEnumerator(text); - while (enumerator.MoveNext()) - { - string grapheme = enumerator.GetTextElement(); - - _logs.Add( - new KeystrokeLog( - Value: grapheme, - Type: KeystrokeType.Correct, - Timestamp: 0, - OffsetMs: _currentOffset += delayMs, - Index: _currentIndex++ - ) - ); - } - return this; - } - - public TelemetryBuilder Error(string actualGrapheme, int delayMs = 100) - { - _logs.Add( - new KeystrokeLog( - Value: actualGrapheme, - Type: KeystrokeType.Incorrect, - Timestamp: 0, - OffsetMs: _currentOffset += delayMs, - Index: _currentIndex // Pointer doesn't move on error - ) - ); - return this; - } - - public List Build() => _logs; -} diff --git a/src/Typical.Core/Statistics/TestResult.cs b/src/Typical.Core/Statistics/TestResult.cs index a9895ee..0bcff37 100644 --- a/src/Typical.Core/Statistics/TestResult.cs +++ b/src/Typical.Core/Statistics/TestResult.cs @@ -8,5 +8,7 @@ public readonly record struct TestResult( Accuracy FinalAccuracy, TimeSpan Duration, TextSample Target, - IReadOnlyList Telemetry + IReadOnlyList Telemetry, + IReadOnlyList Snapshots, + WPM RawWpm ); diff --git a/src/Typical.Core/TypingTest.cs b/src/Typical.Core/TypingTest.cs index 66b984c..5f93612 100644 --- a/src/Typical.Core/TypingTest.cs +++ b/src/Typical.Core/TypingTest.cs @@ -32,6 +32,7 @@ TimeProvider timeProvider } internal Statistics.Statistics Stats { get; private set; } + public TextSample TargetSample { get; private set; } public string TargetText { get; private set; } = string.Empty; public string UserInput => _userInput.ToString(); @@ -56,7 +57,7 @@ public bool ProcessKeyPress(string input, bool isBackspace) { _userInput.Pop(); - Stats.RecordBackspace(); + Stats.RecordBackspace(_userInput.GraphemeCount); } return true; } @@ -69,7 +70,7 @@ public bool ProcessKeyPress(string input, bool isBackspace) bool isCorrect = normalizedInput == _targetGraphemes[currentPos]; var type = isCorrect ? KeystrokeType.Correct : KeystrokeType.Incorrect; - Stats.RecordKey(normalizedInput, type); + Stats.RecordKey(normalizedInput, type, _userInput.GraphemeCount); if (!_gameOptions.ForbidIncorrectEntries || isCorrect) { @@ -89,17 +90,9 @@ private void CheckEndCondition() return; } - IsOver = true; Stats.Stop(); - var snapshot = Stats.CreateSnapshot(); - var result = new TestResult( - DateTime.UtcNow, - snapshot.WPM, - snapshot.Accuracy, - snapshot.ElapsedTime, - SampleNormalized, - Stats.Keystrokes - ); + IsOver = true; + var result = Stats.GetFinalResult(TargetSample); OnTestFinished?.Invoke(this, result); } @@ -107,6 +100,7 @@ private void CheckEndCondition() public void LoadText(TextSample sample) { + TargetSample = sample; TargetText = sample.Text.Normalize(NormalizationForm.FormC); SampleNormalized = sample with { Text = sample.Text.Normalize(NormalizationForm.FormC) }; diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index e51a042..2f5a927 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -1,6 +1,7 @@ using System.Text; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; +using Typical.Core.Data; using Typical.Core.Statistics; namespace Typical.DataAccess.Sqlite; @@ -101,6 +102,7 @@ private async Task InsertSnapshotsAsync( TestResult result ) { + // Use result.Snapshots.Count, not Telemetry.Count if (result.Snapshots.Count == 0) return; @@ -120,6 +122,8 @@ INSERT INTO TestSnapshots (TestId, OffsetMs, Wpm, Accuracy) foreach (var snap in result.Snapshots) { pOffset.Value = (long)snap.ElapsedTime.TotalMilliseconds; + + // Extract values from Vogen ValueObjects pWpm.Value = snap.WPM.Value; pAcc.Value = snap.Accuracy.Value; diff --git a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs index 5757075..b777297 100644 --- a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs +++ b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs @@ -10,10 +10,8 @@ public async Task CreateSnapshot_CalculatesAccurateWPM_BasedOnTime() { var fakeTime = new FakeTimeProvider(); var stats = new Typical.Core.Statistics.Statistics(fakeTime); - stats.Start(); - // Record 6 correct characters ("hello ") - for (int i = 0; i < 6; i++) - stats.RecordKey("a", KeystrokeType.Correct); + + var builder = new TelemetryBuilder(stats, fakeTime).Type("hello "); fakeTime.Advance(TimeSpan.FromSeconds(12)); stats.Stop(); var snapshot = stats.CreateSnapshot(); @@ -26,13 +24,18 @@ public async Task CreateSnapshot_CalculatesAccuracy_WithErrors() { var fakeTime = new FakeTimeProvider(); var stats = new Typical.Core.Statistics.Statistics(fakeTime); - stats.Start(); - for (int i = 0; i < 9; i++) - stats.RecordKey("a", KeystrokeType.Correct); - stats.RecordKey("b", KeystrokeType.Incorrect); + + // Use the builder to simulate a 90% accuracy run + new TelemetryBuilder(stats, fakeTime) + .Type("123456789") // 9 Correct + .Error("x"); // 1 Incorrect + fakeTime.Advance(TimeSpan.FromSeconds(10)); stats.Stop(); + var snapshot = stats.CreateSnapshot(); + + // 9 correct / 10 physical keystrokes = 90% await Assert.That(snapshot.Accuracy.Value).IsEqualTo(90); } diff --git a/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs b/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs index 0e338fc..35b6535 100644 --- a/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs +++ b/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs @@ -8,72 +8,69 @@ public class KeystrokeCollectionTests public async Task Add_CorrectIncrementsCorrectCount() { var kc = new KeystrokeCollection(); - kc.Add("a", KeystrokeType.Correct, 1); + // Updated to include the new 'index' parameter + kc.Add("a", KeystrokeType.Correct, 1000, 0); + await Assert.That(kc.CorrectCount).IsEqualTo(1); - await Assert.That(kc.TotalPhysicalKeystrokes).IsEqualTo(1); + await Assert.That(kc.TotalPhysical).IsEqualTo(1); await Assert.That(kc.ErrorCount).IsEqualTo(0); await Assert.That(kc.CorrectionCount).IsEqualTo(0); } [Test] - public async Task Add_IncorrectIncrementsErrorCount() + public async Task Add_CalculatesRelativeOffsetMs() { var kc = new KeystrokeCollection(); - kc.Add("b", KeystrokeType.Incorrect, 2); - await Assert.That(kc.CorrectCount).IsEqualTo(0); - await Assert.That(kc.ErrorCount).IsEqualTo(1); - await Assert.That(kc.CorrectionCount).IsEqualTo(0); - await Assert.That(kc.TotalPhysicalKeystrokes).IsEqualTo(1); - } + long startTicks = 10_000_000; // 1 second in ticks + long nextTicks = 15_000_000; // 1.5 seconds in ticks (500ms later) - [Test] - public async Task Add_CorrectionIncrementsCorrectionCount() - { - var kc = new KeystrokeCollection(); - kc.Add("c", KeystrokeType.Correction, 3); - await Assert.That(kc.CorrectCount).IsEqualTo(0); - await Assert.That(kc.ErrorCount).IsEqualTo(0); - await Assert.That(kc.CorrectionCount).IsEqualTo(1); - await Assert.That(kc.TotalPhysicalKeystrokes).IsEqualTo(1); + kc.Add("a", KeystrokeType.Correct, startTicks, 0); + kc.Add("b", KeystrokeType.Correct, nextTicks, 1); + + var logs = kc.GetLog(); + + // First key should always have 0 offset + await Assert.That(logs[0].OffsetMs).IsEqualTo(0); + // Second key should be 500ms offset + await Assert.That(logs[1].OffsetMs).IsEqualTo(500); } [Test] - public async Task Add_MixedTypes_TracksAllCounts() + public async Task Add_StoresIndexCorrectly() { var kc = new KeystrokeCollection(); - kc.Add("a", KeystrokeType.Correct, 1); - kc.Add("b", KeystrokeType.Incorrect, 2); - kc.Add("c", KeystrokeType.Correction, 3); - kc.Add("d", KeystrokeType.Correct, 4); - await Assert.That(kc.CorrectCount).IsEqualTo(2); - await Assert.That(kc.ErrorCount).IsEqualTo(1); - await Assert.That(kc.CorrectionCount).IsEqualTo(1); - await Assert.That(kc.TotalPhysicalKeystrokes).IsEqualTo(4); + kc.Add("a", KeystrokeType.Correct, 1000, 42); // Targeted index 42 + + var log = kc.GetLog()[0]; + await Assert.That(log.Index).IsEqualTo(42); } [Test] public async Task Clear_ResetsAllCountsAndLogs() { var kc = new KeystrokeCollection(); - kc.Add("a", KeystrokeType.Correct, 1); - kc.Add("b", KeystrokeType.Incorrect, 2); + kc.Add("a", KeystrokeType.Correct, 1000, 0); + kc.Add("b", KeystrokeType.Incorrect, 2000, 1); + kc.Clear(); - await Assert.That(kc.CorrectCount).IsEqualTo(1); // Counts are not reset by Clear() - await Assert.That(kc.ErrorCount).IsEqualTo(1); + + // These should now all be 0 if you fixed the KeystrokeCollection.Clear() method + await Assert.That(kc.CorrectCount).IsEqualTo(0); + await Assert.That(kc.ErrorCount).IsEqualTo(0); await Assert.That(kc.CorrectionCount).IsEqualTo(0); - await Assert.That(kc.TotalPhysicalKeystrokes).IsEqualTo(0); + await Assert.That(kc.TotalPhysical).IsEqualTo(0); await Assert.That(kc.GetLog().Count).IsEqualTo(0); } [Test] - public async Task GetLog_ReturnsReadOnlyList() + public async Task GetLog_ReturnsDeepDataCorrectly() { var kc = new KeystrokeCollection(); - kc.Add("a", KeystrokeType.Correct, 1); + kc.Add("á", KeystrokeType.Correct, 100, 0); // Testing Unicode grapheme + var log = kc.GetLog(); await Assert.That(log.Count).IsEqualTo(1); - await Assert.That(log[0].Value).IsEqualTo("a"); + await Assert.That(log[0].Value).IsEqualTo("á"); await Assert.That(log[0].Type).IsEqualTo(KeystrokeType.Correct); - await Assert.That(log[0].Timestamp).IsEqualTo(1); } } diff --git a/src/Typical.Tests/Core/Statistics/TelemetryBuilder.cs b/src/Typical.Tests/Core/Statistics/TelemetryBuilder.cs new file mode 100644 index 0000000..27b4003 --- /dev/null +++ b/src/Typical.Tests/Core/Statistics/TelemetryBuilder.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using Microsoft.Extensions.Time.Testing; +using Typical.Core.Statistics; + +namespace Typical.Tests.Core.Statistics; + +public class TelemetryBuilder +{ + private readonly List _logs = new(); + private int _currentIndex = 0; + private readonly Typical.Core.Statistics.Statistics _stats; + private readonly FakeTimeProvider _time; + + public TelemetryBuilder(Typical.Core.Statistics.Statistics stats, FakeTimeProvider fakeTime) + { + _stats = stats; + _time = fakeTime; + } + + public TelemetryBuilder Type(string text, int delayMs = 0) + { + foreach (var grapheme in text.EnumerateRunes()) // Simplification for test + { + _stats.RecordKey(grapheme.ToString(), KeystrokeType.Correct, _currentIndex++); + if (delayMs > 0) + _time.Advance(TimeSpan.FromMilliseconds(delayMs)); + } + return this; + } + + /// + /// Simulates a mistake (Incorrect key). + /// + public TelemetryBuilder Error(string actualGrapheme, int delayMs = 0) + { + // On an error, we record the key at the CURRENT index, + // but we do NOT increment _currentIndex. + _stats.RecordKey(actualGrapheme, KeystrokeType.Incorrect, _currentIndex); + + if (delayMs > 0) + _time.Advance(TimeSpan.FromMilliseconds(delayMs)); + + return this; + } + + /// + /// Simulates hitting backspace. + /// + public TelemetryBuilder Backspace(int delayMs = 0) + { + if (_currentIndex > 0) + { + _currentIndex--; + _stats.RecordBackspace(_currentIndex); + } + + if (delayMs > 0) + _time.Advance(TimeSpan.FromMilliseconds(delayMs)); + + return this; + } + + public TelemetryBuilder Advance(TimeSpan duration) + { + _time.Advance(duration); + return this; + } +} diff --git a/src/Typical.Core/Statistics/TestFactory.cs b/src/Typical.Tests/Core/Statistics/TestFactory.cs similarity index 100% rename from src/Typical.Core/Statistics/TestFactory.cs rename to src/Typical.Tests/Core/Statistics/TestFactory.cs From 36f0a6d3e0871887cc2bf0ed7312df7d1bb9407f Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Wed, 27 May 2026 14:32:25 +0100 Subject: [PATCH 29/58] align namespaces --- README.md | 2 +- src/Typical.Core/Data/IStatsRepository.cs | 2 +- ...Message.cs => StatisticsUpdatedMessage.cs} | 4 +-- src/Typical.Core/Logging/CoreLogs.cs | 24 ++++++++-------- .../Services/ServiceExtensions.cs | 2 +- src/Typical.Core/Statistics/CharacterStats.cs | 3 -- src/Typical.Core/Statistics/TestMetrics.cs | 3 ++ .../{Statistics.cs => TestSession.cs} | 6 ++-- src/Typical.Core/Statistics/TestSnapshot.cs | 6 ++-- .../{GameOptions.cs => TestOptions.cs} | 4 +-- src/Typical.Core/Text/TextSample.cs | 2 +- src/Typical.Core/TypingTest.cs | 18 ++++++------ src/Typical.Core/ViewModels/MainViewModel.cs | 2 +- src/Typical.Core/ViewModels/StatsViewModel.cs | 8 +++--- .../ViewModels/TypingViewModel.cs | 8 +++--- .../Sqlite/SimpleStatsRepository.cs | 2 +- .../Sqlite/StatsRepository.cs | 4 +-- .../Core/Statistics/GameStatsTests.cs | 8 +++--- .../Core/Statistics/TelemetryBuilder.cs | 4 +-- .../Core/Statistics/TestFactory.cs | 10 +++++-- .../Core/Statistics/TypingTestTests.cs | 28 +++++++++---------- .../Core/ViewModels/StatsViewModelTests.cs | 6 ++-- .../Core/ViewModels/TypingViewModelTests.cs | 8 +++--- src/Typical.Tests/UI/BindingTests.cs | 2 +- .../UI/NavigationServiceTests.cs | 9 +++--- src/Typical/Infrastructure/StartupTasks.cs | 2 +- src/Typical/Logging/AppLogs.cs | 4 +-- src/Typical/Navigation/ViewLocator.cs | 2 +- src/Typical/Program.cs | 4 +-- src/Typical/Services/ServiceExtensions.cs | 2 +- .../{ => UI}/Binding/BindingContext.cs | 2 +- .../{ => UI}/Binding/BindingExtensions.cs | 2 +- .../{ => UI}/Binding/DisposableAction.cs | 2 +- src/Typical/{ => UI}/Views/BindableView.cs | 4 +-- src/Typical/{ => UI}/Views/HomeView.cs | 4 +-- src/Typical/{ => UI}/Views/MainShell.cs | 6 ++-- src/Typical/{ => UI}/Views/SettingsView.cs | 4 +-- src/Typical/{ => UI}/Views/StatsView.cs | 2 +- src/Typical/{ => UI}/Views/TypingArea.cs | 2 +- src/Typical/{ => UI}/Views/TypingView.cs | 2 +- 40 files changed, 112 insertions(+), 107 deletions(-) rename src/Typical.Core/Events/{GameStatsUpdatedMessage.cs => StatisticsUpdatedMessage.cs} (86%) delete mode 100644 src/Typical.Core/Statistics/CharacterStats.cs create mode 100644 src/Typical.Core/Statistics/TestMetrics.cs rename src/Typical.Core/Statistics/{Statistics.cs => TestSession.cs} (95%) rename src/Typical.Core/{GameOptions.cs => TestOptions.cs} (69%) rename src/Typical/{ => UI}/Binding/BindingContext.cs (96%) rename src/Typical/{ => UI}/Binding/BindingExtensions.cs (99%) rename src/Typical/{ => UI}/Binding/DisposableAction.cs (94%) rename src/Typical/{ => UI}/Views/BindableView.cs (97%) rename src/Typical/{ => UI}/Views/HomeView.cs (90%) rename src/Typical/{ => UI}/Views/MainShell.cs (97%) rename src/Typical/{ => UI}/Views/SettingsView.cs (91%) rename src/Typical/{ => UI}/Views/StatsView.cs (96%) rename src/Typical/{ => UI}/Views/TypingArea.cs (99%) rename src/Typical/{ => UI}/Views/TypingView.cs (99%) diff --git a/README.md b/README.md index 41ed57c..661a910 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ ## TODO -- add separate stats view and viewmodel that can receive messages during game. +- add separate stats view and viewmodel that can receive messages during test. - finishing wireframes diff --git a/src/Typical.Core/Data/IStatsRepository.cs b/src/Typical.Core/Data/IStatsRepository.cs index 5e751f5..efd1b4b 100644 --- a/src/Typical.Core/Data/IStatsRepository.cs +++ b/src/Typical.Core/Data/IStatsRepository.cs @@ -4,5 +4,5 @@ namespace Typical.Core.Data; public interface IStatsRepository { - Task SaveGameResultAsync(TestResult result); + Task SaveTestResultAsync(TestResult result); } diff --git a/src/Typical.Core/Events/GameStatsUpdatedMessage.cs b/src/Typical.Core/Events/StatisticsUpdatedMessage.cs similarity index 86% rename from src/Typical.Core/Events/GameStatsUpdatedMessage.cs rename to src/Typical.Core/Events/StatisticsUpdatedMessage.cs index daf3d01..574abb1 100644 --- a/src/Typical.Core/Events/GameStatsUpdatedMessage.cs +++ b/src/Typical.Core/Events/StatisticsUpdatedMessage.cs @@ -2,8 +2,8 @@ namespace Typical.Core.Events; -public record StatisticsUpdatedMessage( - TestSnapshot State +public record TestSessionUpdatedMessage( + TestSnapshot Snapshot ); public record WordsMode(int Count, bool Punctuation, bool Numbers); diff --git a/src/Typical.Core/Logging/CoreLogs.cs b/src/Typical.Core/Logging/CoreLogs.cs index b8f1b3d..83cbeb1 100644 --- a/src/Typical.Core/Logging/CoreLogs.cs +++ b/src/Typical.Core/Logging/CoreLogs.cs @@ -5,19 +5,19 @@ namespace Typical.Core.Logging; public static partial class CoreLogs { - // --- GameEngine Logs (2000-2099) --- - [LoggerMessage(EventId = 2000, Level = LogLevel.Information, Message = "New game starting.")] - public static partial void GameStarting(ILogger logger); + // --- TestEngine Logs (2000-2099) --- + [LoggerMessage(EventId = 2000, Level = LogLevel.Information, Message = "New test starting.")] + public static partial void TestStarting(ILogger logger); [LoggerMessage( EventId = 2001, Level = LogLevel.Information, - Message = "Game finished successfully. {Stats}" + Message = "Test finished successfully. {Stats}" )] - public static partial void GameFinished(ILogger logger, TestSnapshot stats); + public static partial void TestFinished(ILogger logger, TestSnapshot stats); - [LoggerMessage(EventId = 2002, Level = LogLevel.Information, Message = "Game quit by user.")] - public static partial void GameQuit(ILogger logger); + [LoggerMessage(EventId = 2002, Level = LogLevel.Information, Message = "Test quit by user.")] + public static partial void TestQuit(ILogger logger); [LoggerMessage( EventId = 2003, @@ -33,22 +33,22 @@ KeystrokeType KeystrokeType [LoggerMessage( EventId = 2004, Level = LogLevel.Trace, - Message = "Publishing game state update." + Message = "Publishing test state update." )] public static partial void PublishingState(ILogger logger); - // --- GameStats Logs (2100-2199) --- - [LoggerMessage(EventId = 2100, Level = LogLevel.Debug, Message = "GameStats started.")] + // --- TestStats Logs (2100-2199) --- + [LoggerMessage(EventId = 2100, Level = LogLevel.Debug, Message = "TestStats started.")] public static partial void StatsStarted(ILogger logger); [LoggerMessage( EventId = 2101, Level = LogLevel.Debug, - Message = "GameStats stopped. Elapsed: {ElapsedTime}ms" + Message = "TestStats stopped. Elapsed: {ElapsedTime}ms" )] public static partial void StatsStopped(ILogger logger, double ElapsedTime); - [LoggerMessage(EventId = 2102, Level = LogLevel.Debug, Message = "GameStats reset.")] + [LoggerMessage(EventId = 2102, Level = LogLevel.Debug, Message = "TestStats reset.")] public static partial void StatsReset(ILogger logger); [LoggerMessage( diff --git a/src/Typical.Core/Services/ServiceExtensions.cs b/src/Typical.Core/Services/ServiceExtensions.cs index fa8f794..1e438d5 100644 --- a/src/Typical.Core/Services/ServiceExtensions.cs +++ b/src/Typical.Core/Services/ServiceExtensions.cs @@ -11,7 +11,7 @@ public static void AddCoreServices(this IServiceCollection services) services.AddMessenger(); services.AddSingleton(TimeProvider.System); services.AddSingleton(); - services.AddSingleton(GameOptions.Default); + services.AddSingleton(TestOptions.Default); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Typical.Core/Statistics/CharacterStats.cs b/src/Typical.Core/Statistics/CharacterStats.cs deleted file mode 100644 index 7993575..0000000 --- a/src/Typical.Core/Statistics/CharacterStats.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Typical.Core.Statistics; - -public record CharacterStats(int Correct, int Incorrect, int Corrections); diff --git a/src/Typical.Core/Statistics/TestMetrics.cs b/src/Typical.Core/Statistics/TestMetrics.cs new file mode 100644 index 0000000..5ab0bb3 --- /dev/null +++ b/src/Typical.Core/Statistics/TestMetrics.cs @@ -0,0 +1,3 @@ +namespace Typical.Core.Statistics; + +public record TestMetrics(int Correct, int Incorrect, int Corrections); diff --git a/src/Typical.Core/Statistics/Statistics.cs b/src/Typical.Core/Statistics/TestSession.cs similarity index 95% rename from src/Typical.Core/Statistics/Statistics.cs rename to src/Typical.Core/Statistics/TestSession.cs index 76e0d74..23e99e3 100644 --- a/src/Typical.Core/Statistics/Statistics.cs +++ b/src/Typical.Core/Statistics/TestSession.cs @@ -2,7 +2,7 @@ namespace Typical.Core.Statistics; -public class Statistics +public class TestSession { private readonly TimeProvider _timeProvider; private readonly KeystrokeCollection _keystrokes = new(); @@ -10,7 +10,7 @@ public class Statistics private long? _startTimestamp; private long? _endTimestamp; - public Statistics(TimeProvider timeProvider) + public TestSession(TimeProvider timeProvider) { _timeProvider = timeProvider; } @@ -58,7 +58,7 @@ private void Reset() public TestSnapshot CreateSnapshot() { - var characterStats = new CharacterStats( + var characterStats = new TestMetrics( _keystrokes.CorrectCount, _keystrokes.ErrorCount, _keystrokes.CorrectionCount diff --git a/src/Typical.Core/Statistics/TestSnapshot.cs b/src/Typical.Core/Statistics/TestSnapshot.cs index 951b37a..b11cb2c 100644 --- a/src/Typical.Core/Statistics/TestSnapshot.cs +++ b/src/Typical.Core/Statistics/TestSnapshot.cs @@ -6,11 +6,11 @@ namespace Typical.Core.Statistics; public readonly record struct TestSnapshot( WPM WPM, Accuracy Accuracy, - CharacterStats Chars, + TestMetrics Chars, TimeSpan ElapsedTime ) { - public static TestSnapshot Create(CharacterStats chars, TimeSpan elapsed) + public static TestSnapshot Create(TestMetrics chars, TimeSpan elapsed) { int totalTyped = chars.Correct + chars.Corrections + chars.Incorrect; double accValue = totalTyped == 0 ? 100.0 : (double)chars.Correct / totalTyped * 100.0; @@ -28,7 +28,7 @@ public static TestSnapshot Create(CharacterStats chars, TimeSpan elapsed) } public static TestSnapshot Empty => - new((WPM)0, (Accuracy)100, new CharacterStats(0, 0, 0), TimeSpan.Zero); + new((WPM)0, (Accuracy)100, new TestMetrics(0, 0, 0), TimeSpan.Zero); } [ValueObject] diff --git a/src/Typical.Core/GameOptions.cs b/src/Typical.Core/TestOptions.cs similarity index 69% rename from src/Typical.Core/GameOptions.cs rename to src/Typical.Core/TestOptions.cs index d0df2e6..698583a 100644 --- a/src/Typical.Core/GameOptions.cs +++ b/src/Typical.Core/TestOptions.cs @@ -1,8 +1,8 @@ namespace Typical.Core; -public record GameOptions +public record TestOptions { - public static GameOptions Default { get; set; } = new(); + public static TestOptions Default { get; set; } = new(); public bool ForbidIncorrectEntries { get; set; } = false; public bool Require100Accuracy { get; set; } = false; public int TargetFrameRate { get; set; } = 60; diff --git a/src/Typical.Core/Text/TextSample.cs b/src/Typical.Core/Text/TextSample.cs index 85042fe..2dfd893 100644 --- a/src/Typical.Core/Text/TextSample.cs +++ b/src/Typical.Core/Text/TextSample.cs @@ -1,7 +1,7 @@ namespace Typical.Core.Text; /// -/// Represents a piece of text to be used in a typing game, +/// Represents a piece of text to be used in a typing test, /// including the text itself and relevant metadata. /// This is a generic DTO, decoupled from any specific data source. /// diff --git a/src/Typical.Core/TypingTest.cs b/src/Typical.Core/TypingTest.cs index 5f93612..1662b2b 100644 --- a/src/Typical.Core/TypingTest.cs +++ b/src/Typical.Core/TypingTest.cs @@ -14,24 +14,24 @@ public class TypingTest { private readonly TypingBuffer _userInput = new(); private string[] _targetGraphemes = []; - private readonly GameOptions _gameOptions; + private readonly TestOptions _testOptions; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public event EventHandler? OnTestFinished; public TypingTest( - GameOptions gameOptions, + TestOptions testOptions, ILogger logger, TimeProvider timeProvider ) { - _gameOptions = gameOptions; + _testOptions = testOptions; _timeProvider = timeProvider; - Stats = new Statistics.Statistics(_timeProvider); + Stats = new TestSession(_timeProvider); _logger = logger; } - internal Statistics.Statistics Stats { get; private set; } + internal TestSession Stats { get; private set; } public TextSample TargetSample { get; private set; } public string TargetText { get; private set; } = string.Empty; @@ -48,7 +48,7 @@ public bool ProcessKeyPress(string input, bool isBackspace) if (!IsRunning && !IsOver && !isBackspace) { Stats.Start(); - CoreLogs.GameStarting(_logger); + CoreLogs.TestStarting(_logger); } if (isBackspace) @@ -72,7 +72,7 @@ public bool ProcessKeyPress(string input, bool isBackspace) Stats.RecordKey(normalizedInput, type, _userInput.GraphemeCount); - if (!_gameOptions.ForbidIncorrectEntries || isCorrect) + if (!_testOptions.ForbidIncorrectEntries || isCorrect) { _userInput.Push(normalizedInput); CheckEndCondition(); @@ -85,7 +85,7 @@ private void CheckEndCondition() { if (_userInput.GraphemeCount == _targetGraphemes.Length) { - if (_gameOptions.Require100Accuracy && _userInput.ToString() != TargetText) + if (_testOptions.Require100Accuracy && _userInput.ToString() != TargetText) { return; } @@ -116,7 +116,7 @@ public void LoadText(TextSample sample) _userInput.Clear(); IsOver = false; - Stats = new Statistics.Statistics(_timeProvider); + Stats = new TestSession(_timeProvider); } internal KeystrokeType GetStatus(int index) diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index d42a8c1..a8b3aeb 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -46,7 +46,7 @@ IMessenger messenger } [RelayCommand] - private void NavigateToGameView() => _navigationService.NavigateTo(); + private void NavigateToTestView() => _navigationService.NavigateTo(); [RelayCommand] private void NavigateHome() => _navigationService.NavigateTo(); diff --git a/src/Typical.Core/ViewModels/StatsViewModel.cs b/src/Typical.Core/ViewModels/StatsViewModel.cs index bf03391..a923859 100644 --- a/src/Typical.Core/ViewModels/StatsViewModel.cs +++ b/src/Typical.Core/ViewModels/StatsViewModel.cs @@ -5,7 +5,7 @@ namespace Typical.Core.ViewModels; -public partial class StatsViewModel : ObservableObject, IRecipient +public partial class StatsViewModel : ObservableObject, IRecipient { private readonly IMessenger _messenger; @@ -15,11 +15,11 @@ public partial class StatsViewModel : ObservableObject, IRecipient(this, (r, m) => r.Receive(m)); + _messenger.Register(this, (r, m) => r.Receive(m)); } - public void Receive(StatisticsUpdatedMessage message) + public void Receive(TestSessionUpdatedMessage message) { - Stats = message.State; + Stats = message.Snapshot; } } diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index 2a56e77..fbe35bd 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -55,11 +55,11 @@ IMessenger messenger _messenger.Register(this, (r, m) => r.Receive(m)); } - public bool IsGameOver => _Test.IsOver; + public bool IsTestOver => _Test.IsOver; /// /// Processes input received from the View. - /// Maps Key events to Core Game Logic. + /// Maps Key events to Core Test Logic. /// public async void ProcessInput(string c, bool isBackspace) { @@ -83,7 +83,7 @@ public async void ProcessInput(string c, bool isBackspace) private void UpdateState() { var snapshot = _Test.CreateSnapshot(); - _messenger.Send(new StatisticsUpdatedMessage(snapshot)); + _messenger.Send(new TestSessionUpdatedMessage(snapshot)); } public void OnNavigatedTo() @@ -141,7 +141,7 @@ private async Task HandleTestFinished(TestResult result) try { - await _statsRepository.SaveGameResultAsync(result); + await _statsRepository.SaveTestResultAsync(result); _messenger.Send(new TestCompletedMessage(result)); } finally diff --git a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs index 5af1028..5e1f8b3 100644 --- a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs @@ -6,7 +6,7 @@ namespace Typical.DataAccess.Sqlite; public class SimpleStatsRepository : IStatsRepository { - public Task SaveGameResultAsync(TestResult result) + public Task SaveTestResultAsync(TestResult result) { Debug.WriteLine("SimpleStatsRepository"); diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index 2f5a927..2c41f26 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -8,7 +8,7 @@ namespace Typical.DataAccess.Sqlite; public class StatsRepository(IOptions options) : IStatsRepository { - public async Task SaveGameResultAsync(TestResult result) + public async Task SaveTestResultAsync(TestResult result) { await using var connection = await GetConnectionAsync(); await using var transaction = connection.BeginTransaction(); @@ -86,7 +86,7 @@ INSERT INTO KeystrokeTelemetry (TestId, OffsetMs, GraphemeIndex, ActualText, Key // Note: result.Telemetry is your List foreach (var log in result.Telemetry) { - pOffset.Value = log.Timestamp; // Relative offset from game start + pOffset.Value = log.Timestamp; // Relative offset from test start pIndex.Value = log.Index; pActual.Value = log.Value; pType.Value = (int)log.Type; diff --git a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs index b777297..fc5ffba 100644 --- a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs +++ b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs @@ -3,13 +3,13 @@ namespace Typical.Tests.Core.Statistics; -public class GameStatsTests +public class TestStatsTests { [Test] public async Task CreateSnapshot_CalculatesAccurateWPM_BasedOnTime() { var fakeTime = new FakeTimeProvider(); - var stats = new Typical.Core.Statistics.Statistics(fakeTime); + var stats = new TestSession(fakeTime); var builder = new TelemetryBuilder(stats, fakeTime).Type("hello "); fakeTime.Advance(TimeSpan.FromSeconds(12)); @@ -23,7 +23,7 @@ public async Task CreateSnapshot_CalculatesAccurateWPM_BasedOnTime() public async Task CreateSnapshot_CalculatesAccuracy_WithErrors() { var fakeTime = new FakeTimeProvider(); - var stats = new Typical.Core.Statistics.Statistics(fakeTime); + var stats = new TestSession(fakeTime); // Use the builder to simulate a 90% accuracy run new TelemetryBuilder(stats, fakeTime) @@ -43,7 +43,7 @@ public async Task CreateSnapshot_CalculatesAccuracy_WithErrors() public async Task CreateSnapshot_HandlesZeroElapsed_PreventsDivideByZero() { var fakeTime = new FakeTimeProvider(); - var stats = new Typical.Core.Statistics.Statistics(fakeTime); + var stats = new TestSession(fakeTime); stats.Start(); stats.Stop(); var snapshot = stats.CreateSnapshot(); diff --git a/src/Typical.Tests/Core/Statistics/TelemetryBuilder.cs b/src/Typical.Tests/Core/Statistics/TelemetryBuilder.cs index 27b4003..c3f4962 100644 --- a/src/Typical.Tests/Core/Statistics/TelemetryBuilder.cs +++ b/src/Typical.Tests/Core/Statistics/TelemetryBuilder.cs @@ -8,10 +8,10 @@ public class TelemetryBuilder { private readonly List _logs = new(); private int _currentIndex = 0; - private readonly Typical.Core.Statistics.Statistics _stats; + private readonly TestSession _stats; private readonly FakeTimeProvider _time; - public TelemetryBuilder(Typical.Core.Statistics.Statistics stats, FakeTimeProvider fakeTime) + public TelemetryBuilder(TestSession stats, FakeTimeProvider fakeTime) { _stats = stats; _time = fakeTime; diff --git a/src/Typical.Tests/Core/Statistics/TestFactory.cs b/src/Typical.Tests/Core/Statistics/TestFactory.cs index 28b794a..6f41730 100644 --- a/src/Typical.Tests/Core/Statistics/TestFactory.cs +++ b/src/Typical.Tests/Core/Statistics/TestFactory.cs @@ -6,9 +6,11 @@ public static class TestFactory { public static TestResult CreateResult( double wpm = 60, + double rawWpm = 0, double accuracy = 100, TextSample? target = null, - List? telemetry = null + List? telemetry = null, + List? snapshots = null ) { return new TestResult( @@ -17,7 +19,9 @@ public static TestResult CreateResult( FinalAccuracy: Accuracy.From(accuracy), Duration: TimeSpan.FromSeconds(10), Target: target ?? TextSample.Empty, - Telemetry: telemetry ?? new List() - ); + Telemetry: telemetry ?? new List(), + Snapshots: snapshots ?? new List() +, + RawWpm: WPM.From(rawWpm)); } } diff --git a/src/Typical.Tests/Core/Statistics/TypingTestTests.cs b/src/Typical.Tests/Core/Statistics/TypingTestTests.cs index 5b0362a..563dc60 100644 --- a/src/Typical.Tests/Core/Statistics/TypingTestTests.cs +++ b/src/Typical.Tests/Core/Statistics/TypingTestTests.cs @@ -16,10 +16,10 @@ public class TypingTestTests { private readonly TimeProvider _timeProvider = new FakeTimeProvider(new DateTime(2025, 01, 01)); private readonly MockTextProvider _mockTextProvider; - private readonly GameOptions _defaultOptions; - private readonly GameOptions _strictOptions; + private readonly TestOptions _defaultOptions; + private readonly TestOptions _strictOptions; private readonly Microsoft.Extensions.Logging.ILogger _logger; - private readonly Typical.Core.Statistics.Statistics _stats; + private readonly TestSession _stats; private const int BOGUS_SEED = 999_999_001; private readonly Random _seed = new Random(BOGUS_SEED); private readonly DefaultLogger _testLogger; @@ -30,16 +30,16 @@ public TypingTestTests() Bogus.Randomizer.Seed = _seed; // This runs before each test, ensuring a clean state. _mockTextProvider = new MockTextProvider(); - _defaultOptions = new GameOptions(); - _strictOptions = new GameOptions { ForbidIncorrectEntries = true }; + _defaultOptions = new TestOptions(); + _strictOptions = new TestOptions { ForbidIncorrectEntries = true }; _logger = NullLogger.Instance; - _stats = new Typical.Core.Statistics.Statistics(_timeProvider); + _stats = new TestSession(_timeProvider); } - // --- StartNewGame Tests --- + // --- StartNewTest Tests --- [Test] - public async Task StartNewGame_Always_LoadsTextFromProvider() + public async Task StartNewTest_Always_LoadsTextFromProvider() { // Arrange var expectedText = "This is a test."; @@ -54,7 +54,7 @@ public async Task StartNewGame_Always_LoadsTextFromProvider() } [Test] - public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() + public async Task StartNewTest_WhenTestWasAlreadyInProgress_ResetsState() { // Arrange // 1. Initial Setup @@ -121,7 +121,7 @@ public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() } [Test] - public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() + public async Task ProcessKeyPress_WhenTestIsCompleted_SetsIsOverToTrue() { // Arrange var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); @@ -137,13 +137,13 @@ public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() await Assert.That(sut.IsRunning).IsFalse(); } - // --- GameOptions: ForbidIncorrectEntries (Strict Mode) Tests --- + // --- TestOptions: ForbidIncorrectEntries (Strict Mode) Tests --- [Test] public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() { // Arrange - var sut = new TypingTest(_strictOptions, _logger, _timeProvider); // _gameOptions.ForbidIncorrectEntries = true + var sut = new TypingTest(_strictOptions, _logger, _timeProvider); // _testOptions.ForbidIncorrectEntries = true sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -173,7 +173,7 @@ public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendChara public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter() { // Arrange - var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); // _gameOptions.ForbidIncorrectEntries = false + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); // _testOptions.ForbidIncorrectEntries = false sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); // Act @@ -187,7 +187,7 @@ public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter( [Test] public async Task ProcessKeyPress_WithRandomText_MatchesState() { - var lorem = new Bogus.DataSets.Lorem("ru") { Random = new Bogus.Randomizer(BOGUS_SEED) }; + var lorem = new Bogus.DataSets.Lorem("ru") { Random = new Randomizer(BOGUS_SEED) }; var text = lorem.Sentence(); diff --git a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs index 354b5a5..cfc02fa 100644 --- a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs @@ -9,7 +9,7 @@ namespace Typical.Tests.Core.ViewModels; public class StatsViewModelTests { [Test] - public async Task Receive_GameStatsUpdatedMessage_UpdatesProperties() + public async Task Receive_TestStatsUpdatedMessage_UpdatesProperties() { var services = new ServiceCollection(); services.AddSingleton(); @@ -20,10 +20,10 @@ public async Task Receive_GameStatsUpdatedMessage_UpdatesProperties() var fakeSnapshot = new TestSnapshot( WPM: (WPM)65.8, Accuracy: (Accuracy)98.5, - Chars: new CharacterStats(10, 1, 2), + Chars: new TestMetrics(10, 1, 2), ElapsedTime: TimeSpan.FromSeconds(30) ); - var msg = new StatisticsUpdatedMessage(fakeSnapshot); + var msg = new TestSessionUpdatedMessage(fakeSnapshot); messenger.Send(msg); diff --git a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs index fd9cdf2..a0973aa 100644 --- a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs @@ -27,7 +27,7 @@ public async Task InitializeAsync_LoadsQuote_FromTextProvider() var mockTextProvider = new MockTextProvider(); mockTextProvider.SetText("Hello world!"); var engine = new TypingTest( - GameOptions.Default, + TestOptions.Default, NullLogger.Instance, TimeProvider.System ); @@ -50,7 +50,7 @@ public async Task ProcessInput_UpdatesEngine_AndBroadcastsMessage() var mockTextProvider = new MockTextProvider(); mockTextProvider.SetText("a"); var engine = new TypingTest( - GameOptions.Default, + TestOptions.Default, NullLogger.Instance, TimeProvider.System ); @@ -69,12 +69,12 @@ public async Task ProcessInput_UpdatesEngine_AndBroadcastsMessage() } [Test] - public async Task Receive_GameResetMessage_ReloadsText_BasedOnSettings() + public async Task Receive_TestResetMessage_ReloadsText_BasedOnSettings() { var mockTextProvider = new MockTextProvider(); mockTextProvider.SetText("reset quote"); var engine = new TypingTest( - GameOptions.Default, + TestOptions.Default, NullLogger.Instance, TimeProvider.System ); diff --git a/src/Typical.Tests/UI/BindingTests.cs b/src/Typical.Tests/UI/BindingTests.cs index 77a16c0..c44d5b9 100644 --- a/src/Typical.Tests/UI/BindingTests.cs +++ b/src/Typical.Tests/UI/BindingTests.cs @@ -2,7 +2,7 @@ using CommunityToolkit.Mvvm.Input; using Terminal.Gui.Input; using Terminal.Gui.Views; -using Typical.Binding; +using Typical.UI.Binding; namespace Typical.Tests.UI; diff --git a/src/Typical.Tests/UI/NavigationServiceTests.cs b/src/Typical.Tests/UI/NavigationServiceTests.cs index 8c9b6e1..bfd236f 100644 --- a/src/Typical.Tests/UI/NavigationServiceTests.cs +++ b/src/Typical.Tests/UI/NavigationServiceTests.cs @@ -11,6 +11,7 @@ using Typical.Core.Text; using Typical.Core.ViewModels; using Typical.Services; +using Typical.UI.Views; [assembly: GenerateImposter(typeof(IDialogService))] [assembly: GenerateImposter(typeof(ITextProvider))] @@ -39,7 +40,7 @@ public void Setup() services.AddSingleton(_statsRepoMock.Instance()); services.AddSingleton(sp => new TypingTest( - new GameOptions(), + new TestOptions(), NullLogger.Instance, TimeProvider.System )); @@ -52,9 +53,9 @@ public void Setup() services.AddTransient(); services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddSingleton(sp => new NavigationService( sp, diff --git a/src/Typical/Infrastructure/StartupTasks.cs b/src/Typical/Infrastructure/StartupTasks.cs index 9ed0553..986453b 100644 --- a/src/Typical/Infrastructure/StartupTasks.cs +++ b/src/Typical/Infrastructure/StartupTasks.cs @@ -6,7 +6,7 @@ using Velopack.Locators; using Velopack.Logging; -namespace MinCh.Infrastructure; +namespace Typical.Infrastructure; public static class StartupTasks { diff --git a/src/Typical/Logging/AppLogs.cs b/src/Typical/Logging/AppLogs.cs index 81b8f82..cbfe3d5 100644 --- a/src/Typical/Logging/AppLogs.cs +++ b/src/Typical/Logging/AppLogs.cs @@ -53,9 +53,9 @@ public static partial class AppLogs [LoggerMessage( EventId = 1003, Level = LogLevel.Warning, - Message = "Starting direct game with Mode: {Mode}, Duration: {Duration}" + Message = "Starting direct test with Mode: {Mode}, Duration: {Duration}" )] - public static partial void StartingGame(ILogger logger, string mode, int duration); + public static partial void StartingTest(ILogger logger, string mode, int duration); [LoggerMessage( EventId = 1004, diff --git a/src/Typical/Navigation/ViewLocator.cs b/src/Typical/Navigation/ViewLocator.cs index 6550766..002960c 100644 --- a/src/Typical/Navigation/ViewLocator.cs +++ b/src/Typical/Navigation/ViewLocator.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Terminal.Gui.ViewBase; using Typical.Core.ViewModels; -using Typical.Views; +using Typical.UI.Views; namespace Typical.Navigation; diff --git a/src/Typical/Program.cs b/src/Typical/Program.cs index 09e89d4..b308fe8 100644 --- a/src/Typical/Program.cs +++ b/src/Typical/Program.cs @@ -1,7 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using MinCh.Infrastructure; +using Typical.Infrastructure; using Serilog; using Terminal.Gui.App; using Typical.Configuration; @@ -9,7 +9,7 @@ using Typical.DataAccess; using Typical.DataAccess.Sqlite; using Typical.Services; -using Typical.Views; +using Typical.UI.Views; using Velopack; VelopackApp.Build().Run(); diff --git a/src/Typical/Services/ServiceExtensions.cs b/src/Typical/Services/ServiceExtensions.cs index 316f895..a156a7e 100644 --- a/src/Typical/Services/ServiceExtensions.cs +++ b/src/Typical/Services/ServiceExtensions.cs @@ -10,7 +10,7 @@ using Typical.Core.Interfaces; using Typical.Logging; using Typical.Navigation; -using Typical.Views; +using Typical.UI.Views; namespace Typical.Services; diff --git a/src/Typical/Binding/BindingContext.cs b/src/Typical/UI/Binding/BindingContext.cs similarity index 96% rename from src/Typical/Binding/BindingContext.cs rename to src/Typical/UI/Binding/BindingContext.cs index 8b5ec41..c557f55 100644 --- a/src/Typical/Binding/BindingContext.cs +++ b/src/Typical/UI/Binding/BindingContext.cs @@ -1,4 +1,4 @@ -namespace Typical.Binding; +namespace Typical.UI.Binding; /// /// Manages the lifecycle of multiple bindings, providing centralized cleanup. diff --git a/src/Typical/Binding/BindingExtensions.cs b/src/Typical/UI/Binding/BindingExtensions.cs similarity index 99% rename from src/Typical/Binding/BindingExtensions.cs rename to src/Typical/UI/Binding/BindingExtensions.cs index e8a46e2..186faf9 100644 --- a/src/Typical/Binding/BindingExtensions.cs +++ b/src/Typical/UI/Binding/BindingExtensions.cs @@ -6,7 +6,7 @@ using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -namespace Typical.Binding; +namespace Typical.UI.Binding; public static class BindingExtensions { diff --git a/src/Typical/Binding/DisposableAction.cs b/src/Typical/UI/Binding/DisposableAction.cs similarity index 94% rename from src/Typical/Binding/DisposableAction.cs rename to src/Typical/UI/Binding/DisposableAction.cs index 6d5534a..63a2b71 100644 --- a/src/Typical/Binding/DisposableAction.cs +++ b/src/Typical/UI/Binding/DisposableAction.cs @@ -1,4 +1,4 @@ -namespace Typical.Binding; +namespace Typical.UI.Binding; /// /// A simple disposable action that executes a delegate when disposed. diff --git a/src/Typical/Views/BindableView.cs b/src/Typical/UI/Views/BindableView.cs similarity index 97% rename from src/Typical/Views/BindableView.cs rename to src/Typical/UI/Views/BindableView.cs index 74f77da..07bb04d 100644 --- a/src/Typical/Views/BindableView.cs +++ b/src/Typical/UI/Views/BindableView.cs @@ -1,10 +1,10 @@ using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.ComponentModel; using Terminal.Gui.ViewBase; -using Typical.Binding; +using Typical.UI.Binding; using Typical.Core.Interfaces; -namespace Typical.Views; +namespace Typical.UI.Views; /// /// Base class for Views that are bound to ViewModels. diff --git a/src/Typical/Views/HomeView.cs b/src/Typical/UI/Views/HomeView.cs similarity index 90% rename from src/Typical/Views/HomeView.cs rename to src/Typical/UI/Views/HomeView.cs index 6b0d0e9..0222bbc 100644 --- a/src/Typical/Views/HomeView.cs +++ b/src/Typical/UI/Views/HomeView.cs @@ -1,9 +1,9 @@ using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -using Typical.Binding; +using Typical.UI.Binding; using Typical.Core.ViewModels; -namespace Typical.Views; +namespace Typical.UI.Views; public class HomeView : BindableView { diff --git a/src/Typical/Views/MainShell.cs b/src/Typical/UI/Views/MainShell.cs similarity index 97% rename from src/Typical/Views/MainShell.cs rename to src/Typical/UI/Views/MainShell.cs index 9b3e458..03f298a 100644 --- a/src/Typical/Views/MainShell.cs +++ b/src/Typical/UI/Views/MainShell.cs @@ -4,11 +4,11 @@ using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -using Typical.Binding; +using Typical.UI.Binding; using Typical.Core.ViewModels; using Typical.Navigation; -namespace Typical.Views; +namespace Typical.UI.Views; public class MainShell : Window { @@ -90,7 +90,7 @@ public MainShell(MainViewModel viewModel, IServiceProvider sp) ) ); - _viewModel.NavigateToGameViewCommand.Execute(null); + _viewModel.NavigateToTestViewCommand.Execute(null); this.Activating += (s, e) => { diff --git a/src/Typical/Views/SettingsView.cs b/src/Typical/UI/Views/SettingsView.cs similarity index 91% rename from src/Typical/Views/SettingsView.cs rename to src/Typical/UI/Views/SettingsView.cs index 9d38f70..e99d280 100644 --- a/src/Typical/Views/SettingsView.cs +++ b/src/Typical/UI/Views/SettingsView.cs @@ -1,9 +1,9 @@ using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -using Typical.Binding; +using Typical.UI.Binding; using Typical.Core.ViewModels; -namespace Typical.Views; +namespace Typical.UI.Views; public class SettingsView : BindableView { diff --git a/src/Typical/Views/StatsView.cs b/src/Typical/UI/Views/StatsView.cs similarity index 96% rename from src/Typical/Views/StatsView.cs rename to src/Typical/UI/Views/StatsView.cs index 600f1ab..0785305 100644 --- a/src/Typical/Views/StatsView.cs +++ b/src/Typical/UI/Views/StatsView.cs @@ -3,7 +3,7 @@ using Terminal.Gui.Views; using Typical.Core.ViewModels; -namespace Typical.Views; +namespace Typical.UI.Views; public class StatsView : BindableView { diff --git a/src/Typical/Views/TypingArea.cs b/src/Typical/UI/Views/TypingArea.cs similarity index 99% rename from src/Typical/Views/TypingArea.cs rename to src/Typical/UI/Views/TypingArea.cs index de9d78c..35c7df0 100644 --- a/src/Typical/Views/TypingArea.cs +++ b/src/Typical/UI/Views/TypingArea.cs @@ -7,7 +7,7 @@ using Typical.Core.ViewModels; using Attribute = Terminal.Gui.Drawing.Attribute; -namespace Typical.Views; +namespace Typical.UI.Views; public class TypingArea : View { diff --git a/src/Typical/Views/TypingView.cs b/src/Typical/UI/Views/TypingView.cs similarity index 99% rename from src/Typical/Views/TypingView.cs rename to src/Typical/UI/Views/TypingView.cs index dd75035..6ca5110 100644 --- a/src/Typical/Views/TypingView.cs +++ b/src/Typical/UI/Views/TypingView.cs @@ -5,7 +5,7 @@ using Terminal.Gui.Views; using Typical.Core.ViewModels; -namespace Typical.Views; +namespace Typical.UI.Views; public class TypingView : BindableView { From e27806cb4a99e513566cb6d6ed3e1667be966368 Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Wed, 27 May 2026 14:46:21 +0100 Subject: [PATCH 30/58] todo: improve storage of textSample in Tests --- .../ViewModels/ResultsViewModel.cs | 1 - src/Typical.DataAccess/ServiceExtensions.cs | 2 +- todo.md | 24 +++++++++---------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/Typical.Core/ViewModels/ResultsViewModel.cs b/src/Typical.Core/ViewModels/ResultsViewModel.cs index 5388d02..ba1b6c5 100644 --- a/src/Typical.Core/ViewModels/ResultsViewModel.cs +++ b/src/Typical.Core/ViewModels/ResultsViewModel.cs @@ -8,6 +8,5 @@ public class ResultsViewModel : ObservableObject public void Initialize(TestResult result) { // TODO: finish implementing display of results - throw new NotImplementedException(); } } diff --git a/src/Typical.DataAccess/ServiceExtensions.cs b/src/Typical.DataAccess/ServiceExtensions.cs index 6bd25a6..f049a67 100644 --- a/src/Typical.DataAccess/ServiceExtensions.cs +++ b/src/Typical.DataAccess/ServiceExtensions.cs @@ -18,7 +18,7 @@ IConfiguration config var options = section.Get(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/todo.md b/todo.md index 673c451..69ff7b9 100644 --- a/todo.md +++ b/todo.md @@ -32,16 +32,16 @@ *Objective: Remove static coupling to allow deterministic testing of time and messaging.* -### Task 1.1: Inject `TimeProvider` into Game Statistics +### Task 1.1: Inject `TimeProvider` into Test Statistics -- **Target File:** `src/Typical.Core/Statistics/GameStats.cs` +- **Target File:** `src/Typical.Core/Statistics/TestStats.cs` - **Action:** - Change the parameterless constructor to accept `TimeProvider timeProvider`. - Assign it to the `_timeProvider` readonly field. - **Target File:** `src/Typical.Core/TypingTest.cs` - **Action:** - Update the constructor to accept `TimeProvider timeProvider`. - - Update the instantiation: `Stats = new GameStats(timeProvider);`. + - Update the instantiation: `Stats = new TestStats(timeProvider);`. - **Target File:** `src/Typical.Tests/TypingTestTests.cs` - **Action:** - Fix compiler errors by passing `TimeProvider.System` (or a `FakeTimeProvider`) to `TypingTest` instantiations in the test setups. @@ -62,15 +62,15 @@ ## 🧪 Phase 2: Core Domain Logic Tests -*Objective: Implement tests for the currently empty GameStats test file.* +*Objective: Implement tests for the currently empty TestStats test file.* ### Task 2.1: WPM and Accuracy Math Tests -- **Target File:** `src/Typical.Tests/Core/GameStatsTests.cs` +- **Target File:** `src/Typical.Tests/Core/TestStatsTests.cs` - **Dependencies to use:** `Microsoft.Extensions.TimeProvider.Testing` (`FakeTimeProvider`), `TUnit`. - **Action:** Implement the following tests: 1. `CreateSnapshot_CalculatesAccurateWPM_BasedOnTime`: - - **Setup:** Create `FakeTimeProvider`, pass to `GameStats`. Call `Start()`. + - **Setup:** Create `FakeTimeProvider`, pass to `TestStats`. Call `Start()`. - **Action:** Record 6 correct characters (e.g., "hello "). Advance `FakeTimeProvider` by exactly 12 seconds. Call `Stop()`. - **Assert:** `WPM` should equal `6` (6 chars / 5 = 1.2 words. 1.2 words in 0.2 minutes = 6 WPM). `Accuracy` should equal `100`. 2. `CreateSnapshot_CalculatesAccuracy_WithErrors`: @@ -90,9 +90,9 @@ - **Target File:** `src/Typical.Tests/StatsViewModelTests.cs` - **Action:** Implement tests using TUnit: - 1. `Receive_GameStatsUpdatedMessage_UpdatesProperties`: + 1. `Receive_TestStatsUpdatedMessage_UpdatesProperties`: - **Setup:** Instantiate `StatsViewModel`. - - **Action:** Call `Receive(new GameStatsUpdatedMessage(mockSnapshot))`. + - **Action:** Call `Receive(new TestStatsUpdatedMessage(mockSnapshot))`. - **Assert:** Verify the ViewModels properties (WPM, Accuracy, ElapsedTime) map exactly to the snapshot's values. ### Task 3.2: Create TypingViewModel Tests @@ -106,9 +106,9 @@ 2. `ProcessInput_UpdatesEngine_AndBroadcastsMessage`: - **Setup:** Inject a mock `IMessenger`. Call `InitializeAsync()`. - **Action:** Call `ProcessInput("a", false)`. - - **Assert:** Verify `_messenger.Send` was called with a `GameStatsUpdatedMessage`. - 3. `Receive_GameResetMessage_ReloadsText_BasedOnSettings`: - - **Action:** Call `Receive(new GameResetMessage(new QuoteMode(QuoteLength.Short)))`. + - **Assert:** Verify `_messenger.Send` was called with a `TestStatsUpdatedMessage`. + 3. `Receive_TestResetMessage_ReloadsText_BasedOnSettings`: + - **Action:** Call `Receive(new TestResetMessage(new QuoteMode(QuoteLength.Short)))`. - **Assert:** Verify `ITextProvider.GetQuoteAsync(QuoteLength.Short)` was called. --- @@ -145,7 +145,7 @@ - **Action:** Add a test `ProcessKeyPress_WithEmoji_HandlesGraphemesCorrectly`: - **Setup:** Load text containing an emoji with a modifier (e.g., `👍🏽`). - **Action:** Call `ProcessKeyPress("👍🏽", false)`. - - **Assert:** Verify `GameStats.TotalPhysicalKeystrokes` counts this as exactly **1** correct keystroke, not multiple bytes. + - **Assert:** Verify `TestStats.TotalPhysicalKeystrokes` counts this as exactly **1** correct keystroke, not multiple bytes. ### Task 5.2: ViewLocator Completeness Test From d72b24a16eec1ccab914be6e56dc5a52760c5687 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 27 May 2026 23:17:51 +0100 Subject: [PATCH 31/58] refactor: rename --- src/Typical.Core/Statistics/TestSnapshot.cs | 2 +- src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Typical.Core/Statistics/TestSnapshot.cs b/src/Typical.Core/Statistics/TestSnapshot.cs index b11cb2c..9c25c51 100644 --- a/src/Typical.Core/Statistics/TestSnapshot.cs +++ b/src/Typical.Core/Statistics/TestSnapshot.cs @@ -6,7 +6,7 @@ namespace Typical.Core.Statistics; public readonly record struct TestSnapshot( WPM WPM, Accuracy Accuracy, - TestMetrics Chars, + TestMetrics Metrics, TimeSpan ElapsedTime ) { diff --git a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs index cfc02fa..da0aba6 100644 --- a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs @@ -20,7 +20,7 @@ public async Task Receive_TestStatsUpdatedMessage_UpdatesProperties() var fakeSnapshot = new TestSnapshot( WPM: (WPM)65.8, Accuracy: (Accuracy)98.5, - Chars: new TestMetrics(10, 1, 2), + Metrics: new TestMetrics(10, 1, 2), ElapsedTime: TimeSpan.FromSeconds(30) ); var msg = new TestSessionUpdatedMessage(fakeSnapshot); @@ -29,9 +29,9 @@ public async Task Receive_TestStatsUpdatedMessage_UpdatesProperties() await Assert.That(sut.Stats.WPM.Value).IsEqualTo(65.8); await Assert.That(sut.Stats.Accuracy.Value).IsEqualTo(98.5); - await Assert.That(sut.Stats.Chars.Correct).IsEqualTo(10); - await Assert.That(sut.Stats.Chars.Incorrect).IsEqualTo(1); - await Assert.That(sut.Stats.Chars.Corrections).IsEqualTo(2); + await Assert.That(sut.Stats.Metrics.Correct).IsEqualTo(10); + await Assert.That(sut.Stats.Metrics.Incorrect).IsEqualTo(1); + await Assert.That(sut.Stats.Metrics.Corrections).IsEqualTo(2); await Assert.That(sut.Stats.ElapsedTime).IsEqualTo(TimeSpan.FromSeconds(30)); } } From 55d569b1c4ac64e4c5341650ad731e014ce3731c Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 27 May 2026 23:21:16 +0100 Subject: [PATCH 32/58] refactor: rename --- .../Statistics/KeystrokeCollection.cs | 3 --- src/Typical.Core/Statistics/TestResult.cs | 4 +-- src/Typical.Core/Statistics/TestSession.cs | 2 +- src/Typical.Core/Statistics/TestSnapshot.cs | 25 +++---------------- .../Statistics/ValueObjects/Accuracy.cs | 12 +++++++++ .../Statistics/ValueObjects/Wpm.cs | 12 +++++++++ .../Core/Statistics/TestFactory.cs | 8 +++--- .../Core/ViewModels/StatsViewModelTests.cs | 2 +- 8 files changed, 35 insertions(+), 33 deletions(-) create mode 100644 src/Typical.Core/Statistics/ValueObjects/Accuracy.cs create mode 100644 src/Typical.Core/Statistics/ValueObjects/Wpm.cs diff --git a/src/Typical.Core/Statistics/KeystrokeCollection.cs b/src/Typical.Core/Statistics/KeystrokeCollection.cs index 1b4d851..17753a1 100644 --- a/src/Typical.Core/Statistics/KeystrokeCollection.cs +++ b/src/Typical.Core/Statistics/KeystrokeCollection.cs @@ -18,11 +18,8 @@ public class KeystrokeCollection /// The current grapheme index in the target text. public void Add(string actual, KeystrokeType type, long timestamp, int index) { - // Set the baseline for OffsetMs on the very first keypress _firstTimestamp ??= timestamp; - // Calculate offset in milliseconds (for SQLite efficiency) - // We use a helper or manual math here depending on your TimeProvider resolution long offsetMs = (timestamp - _firstTimestamp.Value) / TimeSpan.TicksPerMillisecond; var log = new KeystrokeLog( diff --git a/src/Typical.Core/Statistics/TestResult.cs b/src/Typical.Core/Statistics/TestResult.cs index 0bcff37..a24fd43 100644 --- a/src/Typical.Core/Statistics/TestResult.cs +++ b/src/Typical.Core/Statistics/TestResult.cs @@ -4,11 +4,11 @@ namespace Typical.Core.Statistics; public readonly record struct TestResult( DateTime PlayedAt, - WPM FinalWpm, + Wpm FinalWpm, Accuracy FinalAccuracy, TimeSpan Duration, TextSample Target, IReadOnlyList Telemetry, IReadOnlyList Snapshots, - WPM RawWpm + Wpm RawWpm ); diff --git a/src/Typical.Core/Statistics/TestSession.cs b/src/Typical.Core/Statistics/TestSession.cs index 23e99e3..aa98ff3 100644 --- a/src/Typical.Core/Statistics/TestSession.cs +++ b/src/Typical.Core/Statistics/TestSession.cs @@ -78,7 +78,7 @@ internal TestResult GetFinalResult(TextSample targetSample) return new TestResult( PlayedAt: DateTime.UtcNow, FinalWpm: finalSnapshot.WPM, - RawWpm: WPM.From(rawWpm), + RawWpm: Wpm.From(rawWpm), FinalAccuracy: finalSnapshot.Accuracy, Duration: finalSnapshot.ElapsedTime, Target: targetSample, diff --git a/src/Typical.Core/Statistics/TestSnapshot.cs b/src/Typical.Core/Statistics/TestSnapshot.cs index 9c25c51..bf21f8e 100644 --- a/src/Typical.Core/Statistics/TestSnapshot.cs +++ b/src/Typical.Core/Statistics/TestSnapshot.cs @@ -1,10 +1,9 @@ using System.Diagnostics; -using Vogen; namespace Typical.Core.Statistics; public readonly record struct TestSnapshot( - WPM WPM, + Wpm WPM, Accuracy Accuracy, TestMetrics Metrics, TimeSpan ElapsedTime @@ -18,7 +17,7 @@ public static TestSnapshot Create(TestMetrics chars, TimeSpan elapsed) double wpmValue = (minutes <= 0) ? 0 : (chars.Correct / 5.0) / minutes; var snapshot = new TestSnapshot( - WPM.From(Math.Max(0, wpmValue)), + Wpm.From(Math.Max(0, wpmValue)), Accuracy.From(Math.Clamp(accValue, 0, 100)), chars, elapsed @@ -28,23 +27,5 @@ public static TestSnapshot Create(TestMetrics chars, TimeSpan elapsed) } public static TestSnapshot Empty => - new((WPM)0, (Accuracy)100, new TestMetrics(0, 0, 0), TimeSpan.Zero); -} - -[ValueObject] -public partial struct Accuracy -{ - public override string ToString() - { - return $"{Value:F1}"; - } -} - -[ValueObject] -public partial struct WPM -{ - public override string ToString() - { - return $"{Value:F1}"; - } + new((Wpm)0, (Accuracy)100, new TestMetrics(0, 0, 0), TimeSpan.Zero); } diff --git a/src/Typical.Core/Statistics/ValueObjects/Accuracy.cs b/src/Typical.Core/Statistics/ValueObjects/Accuracy.cs new file mode 100644 index 0000000..6648dfe --- /dev/null +++ b/src/Typical.Core/Statistics/ValueObjects/Accuracy.cs @@ -0,0 +1,12 @@ +using Vogen; + +namespace Typical.Core.Statistics; + +[ValueObject] +public readonly partial struct Accuracy +{ + public override string ToString() + { + return $"{Value:F1}"; + } +} diff --git a/src/Typical.Core/Statistics/ValueObjects/Wpm.cs b/src/Typical.Core/Statistics/ValueObjects/Wpm.cs new file mode 100644 index 0000000..14ec440 --- /dev/null +++ b/src/Typical.Core/Statistics/ValueObjects/Wpm.cs @@ -0,0 +1,12 @@ +using Vogen; + +namespace Typical.Core.Statistics; + +[ValueObject] +public readonly partial struct Wpm +{ + public override readonly string ToString() + { + return $"{Value:F1}"; + } +} diff --git a/src/Typical.Tests/Core/Statistics/TestFactory.cs b/src/Typical.Tests/Core/Statistics/TestFactory.cs index 6f41730..31c4c3d 100644 --- a/src/Typical.Tests/Core/Statistics/TestFactory.cs +++ b/src/Typical.Tests/Core/Statistics/TestFactory.cs @@ -15,13 +15,13 @@ public static TestResult CreateResult( { return new TestResult( PlayedAt: DateTime.UtcNow, - FinalWpm: WPM.From(wpm), + FinalWpm: Wpm.From(wpm), FinalAccuracy: Accuracy.From(accuracy), Duration: TimeSpan.FromSeconds(10), Target: target ?? TextSample.Empty, Telemetry: telemetry ?? new List(), - Snapshots: snapshots ?? new List() -, - RawWpm: WPM.From(rawWpm)); + Snapshots: snapshots ?? new List(), + RawWpm: Wpm.From(rawWpm) + ); } } diff --git a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs index da0aba6..af635a3 100644 --- a/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs @@ -18,7 +18,7 @@ public async Task Receive_TestStatsUpdatedMessage_UpdatesProperties() var sut = new StatsViewModel(messenger); var fakeSnapshot = new TestSnapshot( - WPM: (WPM)65.8, + WPM: (Wpm)65.8, Accuracy: (Accuracy)98.5, Metrics: new TestMetrics(10, 1, 2), ElapsedTime: TimeSpan.FromSeconds(30) From efdc3c1e78faacaa0e66539da5c643ab032a1dab Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 27 May 2026 23:32:43 +0100 Subject: [PATCH 33/58] fix: allow delay game reset until UI has time to render final update --- src/Typical.Core/ViewModels/TypingViewModel.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index fbe35bd..2f6400a 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Timers; using CommunityToolkit.Mvvm.ComponentModel; @@ -141,6 +140,8 @@ private async Task HandleTestFinished(TestResult result) try { + await Task.Delay(100); + await _statsRepository.SaveTestResultAsync(result); _messenger.Send(new TestCompletedMessage(result)); } From cd034d8a68aece06e372e08c734c3bf898546795 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Thu, 28 May 2026 00:56:11 +0100 Subject: [PATCH 34/58] feat: persist test telemetry --- .../Services/ServiceExtensions.cs | 2 +- src/Typical.Core/Text/StaticTextProvider.cs | 13 +++++----- src/Typical.DataAccess/Add-MigrationFile.ps1 | 4 ++- .../Migrations/202605230238_AddTestTables.sql | 1 - .../202605272341_UpdateTestMetadata.sql | 9 +++++++ .../Sqlite/StatsRepository.cs | 25 +++++++++++-------- 6 files changed, 34 insertions(+), 20 deletions(-) create mode 100644 src/Typical.DataAccess/Migrations/202605272341_UpdateTestMetadata.sql diff --git a/src/Typical.Core/Services/ServiceExtensions.cs b/src/Typical.Core/Services/ServiceExtensions.cs index 1e438d5..3c95d54 100644 --- a/src/Typical.Core/Services/ServiceExtensions.cs +++ b/src/Typical.Core/Services/ServiceExtensions.cs @@ -10,7 +10,7 @@ public static void AddCoreServices(this IServiceCollection services) { services.AddMessenger(); services.AddSingleton(TimeProvider.System); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(TestOptions.Default); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Typical.Core/Text/StaticTextProvider.cs b/src/Typical.Core/Text/StaticTextProvider.cs index 5207969..65500f2 100644 --- a/src/Typical.Core/Text/StaticTextProvider.cs +++ b/src/Typical.Core/Text/StaticTextProvider.cs @@ -4,19 +4,20 @@ namespace Typical.Core.Text; -public class StaticTextProvider(ITextRepository textRepository) : ITextProvider +public class TextProvider(ITextRepository textRepository) : ITextProvider { private readonly Faker _faker = new Faker("en_GB"); public async Task GetQuoteAsync(QuoteLength? length = null) { - var result = await textRepository.GetRandomQuoteAsync(); + var quote = await textRepository.GetRandomQuoteAsync(); return new TextSample() { - Source = result.Author, - Text = result.Text, - CharCount = result.CharCount, - WordCount = result.WordCount, + SourceId = quote.Id, + Source = quote.Author, + Text = quote.Text, + CharCount = quote.CharCount, + WordCount = quote.WordCount, }; } diff --git a/src/Typical.DataAccess/Add-MigrationFile.ps1 b/src/Typical.DataAccess/Add-MigrationFile.ps1 index 27cc422..c2dfc1b 100644 --- a/src/Typical.DataAccess/Add-MigrationFile.ps1 +++ b/src/Typical.DataAccess/Add-MigrationFile.ps1 @@ -1,3 +1,5 @@ $dateString = Get-Date -Format 'yyyyMMddHHmm' -$file = 'Script_' + $dateString + '_description.sql' +$file = $dateString + '_description.sql' + +new-item "$PSScriptRoot/Migrations/$file" diff --git a/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql index d0e943f..49444d2 100644 --- a/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql +++ b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql @@ -26,7 +26,6 @@ CREATE TABLE TestSnapshots ( OffsetMs INTEGER NOT NULL, Wpm REAL NOT NULL, Accuracy REAL NOT NULL, - PRIMARY KEY (TestId, OffsetMs), FOREIGN KEY (TestId) REFERENCES Tests(Id) ON DELETE CASCADE ) WITHOUT ROWID; diff --git a/src/Typical.DataAccess/Migrations/202605272341_UpdateTestMetadata.sql b/src/Typical.DataAccess/Migrations/202605272341_UpdateTestMetadata.sql new file mode 100644 index 0000000..51b19ef --- /dev/null +++ b/src/Typical.DataAccess/Migrations/202605272341_UpdateTestMetadata.sql @@ -0,0 +1,9 @@ +ALTER TABLE Tests ADD COLUMN QuoteId INTEGER REFERENCES Quotes(Id); +ALTER TABLE Tests ADD COLUMN CustomText TEXT; + +CREATE INDEX IX_Tests_QuoteId ON Tests (QuoteId); +CREATE INDEX IX_Tests_CreatedAt ON Tests (CreatedAt DESC); + +UPDATE Tests SET CustomText = TargetText; + +UPDATE Tests SET TargetText = NULL WHERE QuoteId IS NOT NULL OR CustomText IS NOT NULL; diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index 2c41f26..bdc0e47 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; @@ -15,13 +16,10 @@ public async Task SaveTestResultAsync(TestResult result) try { - // 1. Insert the Test Header long testId = await InsertTestHeaderAsync(connection, transaction, result); - // 2. Insert Keystroke Telemetry (Bulk) await InsertTelemetryAsync(connection, transaction, testId, result); - // 3. Insert Snapshots (Bulk) await InsertSnapshotsAsync(connection, transaction, testId, result); await transaction.CommitAsync(); @@ -41,13 +39,12 @@ TestResult result { const string sql = """ INSERT INTO Tests (CreatedAt, Wpm, RawWpm, Accuracy, DurationMs, TargetText, Source) - VALUES (@CreatedAt, @Wpm, @RawWpm, @Accuracy, @DurationMs, @TargetText, @Source); + VALUES (@CreatedAt, @Wpm, @RawWpm, @Accuracy, @DurationMs, @QuoteId, @CustomText); SELECT last_insert_rowid(); """; await using var cmd = new SqliteCommand(sql, conn, trans); - // Convert DateTime to Unix Milliseconds (Standard for SQLite INTEGER) cmd.Parameters.AddWithValue( "@CreatedAt", new DateTimeOffset(result.PlayedAt).ToUnixTimeMilliseconds() @@ -56,8 +53,17 @@ INSERT INTO Tests (CreatedAt, Wpm, RawWpm, Accuracy, DurationMs, TargetText, Sou cmd.Parameters.AddWithValue("@RawWpm", result.RawWpm.Value); cmd.Parameters.AddWithValue("@Accuracy", result.FinalAccuracy.Value); cmd.Parameters.AddWithValue("@DurationMs", (long)result.Duration.TotalMilliseconds); - cmd.Parameters.AddWithValue("@TargetText", result.Target); - cmd.Parameters.AddWithValue("@Source", result.Target.Source ?? (object)DBNull.Value); + + if (result.Target.SourceId.HasValue) + { + cmd.Parameters.AddWithValue("@QuoteId", result.Target.SourceId.Value); + cmd.Parameters.AddWithValue("@CustomText", DBNull.Value); + } + else + { + cmd.Parameters.AddWithValue("@QuoteId", DBNull.Value); + cmd.Parameters.AddWithValue("@CustomText", result.Target.Text); + } return (long)(await cmd.ExecuteScalarAsync() ?? 0L); } @@ -83,10 +89,9 @@ INSERT INTO KeystrokeTelemetry (TestId, OffsetMs, GraphemeIndex, ActualText, Key pTestId.Value = testId; - // Note: result.Telemetry is your List foreach (var log in result.Telemetry) { - pOffset.Value = log.Timestamp; // Relative offset from test start + pOffset.Value = log.Timestamp; pIndex.Value = log.Index; pActual.Value = log.Value; pType.Value = (int)log.Type; @@ -102,7 +107,6 @@ private async Task InsertSnapshotsAsync( TestResult result ) { - // Use result.Snapshots.Count, not Telemetry.Count if (result.Snapshots.Count == 0) return; @@ -123,7 +127,6 @@ INSERT INTO TestSnapshots (TestId, OffsetMs, Wpm, Accuracy) { pOffset.Value = (long)snap.ElapsedTime.TotalMilliseconds; - // Extract values from Vogen ValueObjects pWpm.Value = snap.WPM.Value; pAcc.Value = snap.Accuracy.Value; From 548d4a43df042773e5059f9e06be958a702c17e9 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Thu, 28 May 2026 01:30:45 +0100 Subject: [PATCH 35/58] wip --- src/Typical.Core/Statistics/TestSession.cs | 16 +++++++++---- src/Typical.Core/TypingTest.cs | 2 +- src/Typical.Core/ViewModels/MainViewModel.cs | 15 +++++++++++- .../ViewModels/TypingViewModel.cs | 24 ++++--------------- .../Sqlite/StatsRepository.cs | 3 ++- .../Core/Statistics/GameStatsTests.cs | 6 ++--- 6 files changed, 35 insertions(+), 31 deletions(-) diff --git a/src/Typical.Core/Statistics/TestSession.cs b/src/Typical.Core/Statistics/TestSession.cs index aa98ff3..15cee04 100644 --- a/src/Typical.Core/Statistics/TestSession.cs +++ b/src/Typical.Core/Statistics/TestSession.cs @@ -56,23 +56,29 @@ private void Reset() internal void Stop() => _endTimestamp = _timeProvider.GetTimestamp(); - public TestSnapshot CreateSnapshot() + public TestSnapshot GetCurrentSnapshot() { var characterStats = new TestMetrics( _keystrokes.CorrectCount, _keystrokes.ErrorCount, _keystrokes.CorrectionCount ); - var snapshot = TestSnapshot.Create(characterStats, ElapsedTime); + return TestSnapshot.Create(characterStats, ElapsedTime); + } + public void SampleSnapshot() + { + var snapshot = GetCurrentSnapshot(); + if (_snapshots.Count > 0 && _snapshots[^1].ElapsedTime == snapshot.ElapsedTime) + { + return; + } _snapshots.Add(snapshot); - - return snapshot; } internal TestResult GetFinalResult(TextSample targetSample) { - var finalSnapshot = CreateSnapshot(); + var finalSnapshot = GetCurrentSnapshot(); double minutes = ElapsedTime.Minutes; double rawWpm = (minutes <= 0) ? 0 : (_keystrokes.TotalPhysical / 5.0) / minutes; return new TestResult( diff --git a/src/Typical.Core/TypingTest.cs b/src/Typical.Core/TypingTest.cs index 1662b2b..5da1234 100644 --- a/src/Typical.Core/TypingTest.cs +++ b/src/Typical.Core/TypingTest.cs @@ -41,7 +41,7 @@ TimeProvider timeProvider public TextSample SampleNormalized { get; private set; } = TextSample.Empty; - public TestSnapshot CreateSnapshot() => Stats.CreateSnapshot(); + public TestSnapshot GetCurrentSnapshot() => Stats.GetCurrentSnapshot(); public bool ProcessKeyPress(string input, bool isBackspace) { diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index a8b3aeb..8bca381 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -4,10 +4,14 @@ using Microsoft.Extensions.Logging; using Typical.Core.Events; using Typical.Core.Interfaces; +using Typical.Core.Text; namespace Typical.Core.ViewModels; -public sealed partial class MainViewModel : ObservableObject, IRecipient +public sealed partial class MainViewModel + : ObservableObject, + IRecipient, + IRecipient { private readonly INavigationService _navigationService; private readonly IDialogService _dialogService; @@ -43,6 +47,7 @@ IMessenger messenger _messenger = messenger; _messenger.Register(this, (r, m) => r.Receive(m)); + _messenger.Register(this, (r, m) => r.Receive(m)); } [RelayCommand] @@ -64,4 +69,12 @@ public void Receive(TestCompletedMessage message) { _navigationService.NavigateTo(vm => vm.Initialize(message.Result)); } + + public async void Receive(TestResetMessage message) + { + _navigationService.NavigateTo(vm => + { + vm.InitializeAsync().Wait(); + }); + } } diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index 2f6400a..ae0dadb 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -12,10 +12,7 @@ namespace Typical.Core.ViewModels; -public partial class TypingViewModel - : ObservableObject, - INavigatableView, - IRecipient +public partial class TypingViewModel : ObservableObject, INavigatableView { private readonly TypingTest _Test; private readonly ITextProvider _textProvider; @@ -50,8 +47,6 @@ IMessenger messenger _refreshTimer = new Timer(100); _refreshTimer.AutoReset = true; _refreshTimer.Elapsed += OnRefreshTimerElapsed; - - _messenger.Register(this, (r, m) => r.Receive(m)); } public bool IsTestOver => _Test.IsOver; @@ -81,7 +76,7 @@ public async void ProcessInput(string c, bool isBackspace) /// private void UpdateState() { - var snapshot = _Test.CreateSnapshot(); + var snapshot = _Test.GetCurrentSnapshot(); _messenger.Send(new TestSessionUpdatedMessage(snapshot)); } @@ -104,6 +99,8 @@ private void OnRefreshTimerElapsed(object? sender, ElapsedEventArgs e) return; } + _Test.Stats.SampleSnapshot(); + RefreshRequested?.Invoke(this, EventArgs.Empty); } @@ -114,19 +111,6 @@ public async Task InitializeAsync(TextSample? textSample = null) UpdateState(); } - public async void Receive(TestResetMessage message) - { - TextSample textSample = message.Settings switch - { - QuoteMode q => (await _textProvider.GetQuoteAsync(q.Length)), - _ => throw new InvalidOperationException( - $"Unsupported mode settings type: {message.Settings.Value?.GetType().Name ?? message.Settings.GetType().Name}" - ), - }; - - await InitializeAsync(textSample); - } - public KeystrokeType GetStatus(int globalIdx) { return _Test.GetStatus(globalIdx); diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index bdc0e47..4d947b4 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -38,7 +38,7 @@ TestResult result ) { const string sql = """ - INSERT INTO Tests (CreatedAt, Wpm, RawWpm, Accuracy, DurationMs, TargetText, Source) + INSERT INTO Tests (CreatedAt, Wpm, RawWpm, Accuracy, DurationMs, QuoteId, CustomText) VALUES (@CreatedAt, @Wpm, @RawWpm, @Accuracy, @DurationMs, @QuoteId, @CustomText); SELECT last_insert_rowid(); """; @@ -125,6 +125,7 @@ INSERT INTO TestSnapshots (TestId, OffsetMs, Wpm, Accuracy) foreach (var snap in result.Snapshots) { + pTestId.Value = testId; pOffset.Value = (long)snap.ElapsedTime.TotalMilliseconds; pWpm.Value = snap.WPM.Value; diff --git a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs index fc5ffba..5812b1e 100644 --- a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs +++ b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs @@ -14,7 +14,7 @@ public async Task CreateSnapshot_CalculatesAccurateWPM_BasedOnTime() var builder = new TelemetryBuilder(stats, fakeTime).Type("hello "); fakeTime.Advance(TimeSpan.FromSeconds(12)); stats.Stop(); - var snapshot = stats.CreateSnapshot(); + var snapshot = stats.SampleSnapshot(); await Assert.That(snapshot.WPM.Value).IsEqualTo(6).Within(0.0001); await Assert.That(snapshot.Accuracy.Value).IsEqualTo(100); } @@ -33,7 +33,7 @@ public async Task CreateSnapshot_CalculatesAccuracy_WithErrors() fakeTime.Advance(TimeSpan.FromSeconds(10)); stats.Stop(); - var snapshot = stats.CreateSnapshot(); + var snapshot = stats.SampleSnapshot(); // 9 correct / 10 physical keystrokes = 90% await Assert.That(snapshot.Accuracy.Value).IsEqualTo(90); @@ -46,7 +46,7 @@ public async Task CreateSnapshot_HandlesZeroElapsed_PreventsDivideByZero() var stats = new TestSession(fakeTime); stats.Start(); stats.Stop(); - var snapshot = stats.CreateSnapshot(); + var snapshot = stats.SampleSnapshot(); await Assert.That(snapshot.WPM.Value).IsEqualTo(0); await Assert.That(snapshot.Accuracy.Value).IsEqualTo(100); } From 3b95fa3a024e62803adc38905beccb2230e9f75c Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Thu, 28 May 2026 08:24:11 +0100 Subject: [PATCH 36/58] wip: ResultsView --- .../Migrations/202605230238_AddTestTables.sql | 22 +++++++++++++------ .../202605272341_UpdateTestMetadata.sql | 9 -------- src/Typical/Navigation/ViewLocator.cs | 7 +----- src/Typical/Services/ServiceExtensions.cs | 1 - src/Typical/UI/Views/ResultsView.cs | 20 +++++++++++++++++ 5 files changed, 36 insertions(+), 23 deletions(-) delete mode 100644 src/Typical.DataAccess/Migrations/202605272341_UpdateTestMetadata.sql create mode 100644 src/Typical/UI/Views/ResultsView.cs diff --git a/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql index 49444d2..401cd24 100644 --- a/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql +++ b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql @@ -1,13 +1,18 @@ CREATE TABLE Tests ( - Id INTEGER PRIMARY KEY, + Id INTEGER PRIMARY KEY AUTOINCREMENT, CreatedAt INTEGER NOT NULL, - Wpm REAL NOT NULL, - RawWpm REAL NOT NULL, - Accuracy REAL NOT NULL, - DurationMs INTEGER NOT NULL, - TargetText TEXT NOT NULL, - Source TEXT + Wpm REAL, + RawWpm REAL, + Accuracy REAL, + DurationMs INTEGER, + + -- TargetText is now NULLABLE + TargetText TEXT NULL, + + -- New hybrid columns + QuoteId INTEGER NULL REFERENCES Quotes(Id), + CustomText TEXT NULL ); CREATE TABLE KeystrokeTelemetry ( @@ -29,3 +34,6 @@ CREATE TABLE TestSnapshots ( PRIMARY KEY (TestId, OffsetMs), FOREIGN KEY (TestId) REFERENCES Tests(Id) ON DELETE CASCADE ) WITHOUT ROWID; + +CREATE INDEX IX_Tests_QuoteId ON Tests (QuoteId); +CREATE INDEX IX_Tests_CreatedAt ON Tests (CreatedAt DESC); diff --git a/src/Typical.DataAccess/Migrations/202605272341_UpdateTestMetadata.sql b/src/Typical.DataAccess/Migrations/202605272341_UpdateTestMetadata.sql deleted file mode 100644 index 51b19ef..0000000 --- a/src/Typical.DataAccess/Migrations/202605272341_UpdateTestMetadata.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE Tests ADD COLUMN QuoteId INTEGER REFERENCES Quotes(Id); -ALTER TABLE Tests ADD COLUMN CustomText TEXT; - -CREATE INDEX IX_Tests_QuoteId ON Tests (QuoteId); -CREATE INDEX IX_Tests_CreatedAt ON Tests (CreatedAt DESC); - -UPDATE Tests SET CustomText = TargetText; - -UPDATE Tests SET TargetText = NULL WHERE QuoteId IS NOT NULL OR CustomText IS NOT NULL; diff --git a/src/Typical/Navigation/ViewLocator.cs b/src/Typical/Navigation/ViewLocator.cs index 002960c..f34158e 100644 --- a/src/Typical/Navigation/ViewLocator.cs +++ b/src/Typical/Navigation/ViewLocator.cs @@ -1,3 +1,4 @@ +using System.Reflection.Emit; using Microsoft.Extensions.DependencyInjection; using Terminal.Gui.ViewBase; using Typical.Core.ViewModels; @@ -17,9 +18,3 @@ public static View GetView(IServiceProvider sp, object viewModel) => _ => throw new ArgumentException($"No view registered for {viewModel.GetType()}"), }; } - -public class ResultsView : BindableView -{ - public ResultsView(ResultsViewModel viewModel) - : base(viewModel) { } -} diff --git a/src/Typical/Services/ServiceExtensions.cs b/src/Typical/Services/ServiceExtensions.cs index a156a7e..2b9ac63 100644 --- a/src/Typical/Services/ServiceExtensions.cs +++ b/src/Typical/Services/ServiceExtensions.cs @@ -9,7 +9,6 @@ using Typical.Configuration; using Typical.Core.Interfaces; using Typical.Logging; -using Typical.Navigation; using Typical.UI.Views; namespace Typical.Services; diff --git a/src/Typical/UI/Views/ResultsView.cs b/src/Typical/UI/Views/ResultsView.cs new file mode 100644 index 0000000..b1fb9db --- /dev/null +++ b/src/Typical/UI/Views/ResultsView.cs @@ -0,0 +1,20 @@ +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Typical.Core.ViewModels; + +namespace Typical.UI.Views; + +public class ResultsView : BindableView +{ + public ResultsView(ResultsViewModel viewModel) + : base(viewModel) + { + var text = new TextField() { Text = "Results View" }; + text.X = Pos.Center(); + text.Y = Pos.Center(); + text.Width = Dim.Fill(); + text.Height = Dim.Fill(); + + Add(text); + } +} From 794d51103344dc3de8ac2e58447e490ca08f8b24 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Fri, 29 May 2026 22:49:05 +0100 Subject: [PATCH 37/58] wip: ResultsView --- .../ViewModels/TypingViewModel.cs | 3 -- src/Typical/Navigation/ViewLocator.cs | 2 +- src/Typical/Services/ServiceExtensions.cs | 2 +- src/Typical/UI/Views/BindableView.cs | 29 +++++++++++++++++-- src/Typical/UI/Views/ResultsView.cs | 25 +++++++++------- 5 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index ae0dadb..6195e08 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -21,7 +21,6 @@ public partial class TypingViewModel : ObservableObject, INavigatableView private readonly IMessenger _messenger; private readonly Timer _refreshTimer; private bool _isFinishing; - public event EventHandler? RefreshRequested; [ObservableProperty] @@ -49,8 +48,6 @@ IMessenger messenger _refreshTimer.Elapsed += OnRefreshTimerElapsed; } - public bool IsTestOver => _Test.IsOver; - /// /// Processes input received from the View. /// Maps Key events to Core Test Logic. diff --git a/src/Typical/Navigation/ViewLocator.cs b/src/Typical/Navigation/ViewLocator.cs index f34158e..d2d654c 100644 --- a/src/Typical/Navigation/ViewLocator.cs +++ b/src/Typical/Navigation/ViewLocator.cs @@ -14,7 +14,7 @@ public static View GetView(IServiceProvider sp, object viewModel) => HomeViewModel => sp.GetRequiredService(), SettingsViewModel => sp.GetRequiredService(), TypingViewModel => sp.GetRequiredService(), - ResultsViewModel => sp.GetRequiredService(), + ResultsViewModel => sp.GetRequiredService(), _ => throw new ArgumentException($"No view registered for {viewModel.GetType()}"), }; } diff --git a/src/Typical/Services/ServiceExtensions.cs b/src/Typical/Services/ServiceExtensions.cs index 2b9ac63..0058a5c 100644 --- a/src/Typical/Services/ServiceExtensions.cs +++ b/src/Typical/Services/ServiceExtensions.cs @@ -57,7 +57,7 @@ public static void AddTuiInfrastructure(this HostApplicationBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); } } diff --git a/src/Typical/UI/Views/BindableView.cs b/src/Typical/UI/Views/BindableView.cs index 07bb04d..502e256 100644 --- a/src/Typical/UI/Views/BindableView.cs +++ b/src/Typical/UI/Views/BindableView.cs @@ -1,11 +1,36 @@ +using System.ComponentModel; using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.ComponentModel; using Terminal.Gui.ViewBase; -using Typical.UI.Binding; +using Terminal.Gui.Views; using Typical.Core.Interfaces; +using Typical.UI.Binding; namespace Typical.UI.Views; +public class TypicalDialog : Dialog + where TViewModel : ObservableObject +{ + private readonly TViewModel _viewModel; + private bool _disposed; + + public TypicalDialog(TViewModel viewModel) + { + _viewModel = viewModel; + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + // BindingContext.Dispose(); + _disposed = true; + } + var pos = Pos.Absolute(1); + base.Dispose(disposing); + } +} + /// /// Base class for Views that are bound to ViewModels. /// Provides lifecycle management and binding context. @@ -30,7 +55,7 @@ public abstract class BindableView : View, INavigatableView /// protected BindableView(TViewModel viewModel) { - ViewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + ViewModel = viewModel; BindingContext = new BindingContext(); Initialized += (s, e) => SetupBindings(); diff --git a/src/Typical/UI/Views/ResultsView.cs b/src/Typical/UI/Views/ResultsView.cs index b1fb9db..5dd9002 100644 --- a/src/Typical/UI/Views/ResultsView.cs +++ b/src/Typical/UI/Views/ResultsView.cs @@ -1,20 +1,23 @@ -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; +using Terminal.Gui.Input; using Typical.Core.ViewModels; namespace Typical.UI.Views; -public class ResultsView : BindableView +public class ResultsDialog : TypicalDialog { - public ResultsView(ResultsViewModel viewModel) - : base(viewModel) + protected override bool OnAccepting(CommandEventArgs args) { - var text = new TextField() { Text = "Results View" }; - text.X = Pos.Center(); - text.Y = Pos.Center(); - text.Width = Dim.Fill(); - text.Height = Dim.Fill(); + if (base.OnAccepting(args)) + { + return true; + } + return false; + } - Add(text); + public ResultsDialog(ResultsViewModel viewModel) + : base(viewModel) + { + AddButton(new() { Text = "_Cancel" }); + AddButton(new() { Text = "_Ok" }); } } From 812a5cd8b047778000da2e50d9e45339e6dc22ce Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Fri, 29 May 2026 22:53:01 +0100 Subject: [PATCH 38/58] test: remove obsolete test --- .../Core/Statistics/GameStatsTests.cs | 6 ++--- .../Core/ViewModels/TypingViewModelTests.cs | 26 ------------------- 2 files changed, 3 insertions(+), 29 deletions(-) diff --git a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs index 5812b1e..0b3176b 100644 --- a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs +++ b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs @@ -14,7 +14,7 @@ public async Task CreateSnapshot_CalculatesAccurateWPM_BasedOnTime() var builder = new TelemetryBuilder(stats, fakeTime).Type("hello "); fakeTime.Advance(TimeSpan.FromSeconds(12)); stats.Stop(); - var snapshot = stats.SampleSnapshot(); + var snapshot = stats.GetCurrentSnapshot(); await Assert.That(snapshot.WPM.Value).IsEqualTo(6).Within(0.0001); await Assert.That(snapshot.Accuracy.Value).IsEqualTo(100); } @@ -33,7 +33,7 @@ public async Task CreateSnapshot_CalculatesAccuracy_WithErrors() fakeTime.Advance(TimeSpan.FromSeconds(10)); stats.Stop(); - var snapshot = stats.SampleSnapshot(); + var snapshot = stats.GetCurrentSnapshot(); // 9 correct / 10 physical keystrokes = 90% await Assert.That(snapshot.Accuracy.Value).IsEqualTo(90); @@ -46,7 +46,7 @@ public async Task CreateSnapshot_HandlesZeroElapsed_PreventsDivideByZero() var stats = new TestSession(fakeTime); stats.Start(); stats.Stop(); - var snapshot = stats.SampleSnapshot(); + var snapshot = stats.GetCurrentSnapshot(); await Assert.That(snapshot.WPM.Value).IsEqualTo(0); await Assert.That(snapshot.Accuracy.Value).IsEqualTo(100); } diff --git a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs index a0973aa..bc915ff 100644 --- a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs +++ b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs @@ -67,30 +67,4 @@ public async Task ProcessInput_UpdatesEngine_AndBroadcastsMessage() // Assert observable state: engine should have processed the input await Assert.That(engine.UserInput).IsEqualTo("a"); } - - [Test] - public async Task Receive_TestResetMessage_ReloadsText_BasedOnSettings() - { - var mockTextProvider = new MockTextProvider(); - mockTextProvider.SetText("reset quote"); - var engine = new TypingTest( - TestOptions.Default, - NullLogger.Instance, - TimeProvider.System - ); - var mockMessenger = IMessenger.Imposter(); - var mockNavigationService = INavigationService.Imposter(); - var vm = new TypingViewModel( - engine, - mockTextProvider, - mockStatsRepository.Instance(), - mockNavigationService.Instance(), - NullLogger.Instance, - mockMessenger.Instance() - ); - var msg = new TestResetMessage(new QuoteMode(QuoteLength.Short)); - vm.Receive(msg); - await Task.Delay(10); // Allow async to complete - await Assert.That(vm.Target.Text).IsEqualTo("reset quote"); - } } From 43b5a7fa34672d2155eec9da186bc271502d7576 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Fri, 29 May 2026 22:55:15 +0100 Subject: [PATCH 39/58] chore: fix warnings --- src/Typical.Core/TypingTest.cs | 2 +- src/Typical.DataAccess/Typical.DataAccess.csproj | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Typical.Core/TypingTest.cs b/src/Typical.Core/TypingTest.cs index 5da1234..3732359 100644 --- a/src/Typical.Core/TypingTest.cs +++ b/src/Typical.Core/TypingTest.cs @@ -32,7 +32,7 @@ TimeProvider timeProvider } internal TestSession Stats { get; private set; } - public TextSample TargetSample { get; private set; } + public TextSample TargetSample { get; private set; } = TextSample.Empty; public string TargetText { get; private set; } = string.Empty; public string UserInput => _userInput.ToString(); diff --git a/src/Typical.DataAccess/Typical.DataAccess.csproj b/src/Typical.DataAccess/Typical.DataAccess.csproj index 5c4b599..04e10bd 100644 --- a/src/Typical.DataAccess/Typical.DataAccess.csproj +++ b/src/Typical.DataAccess/Typical.DataAccess.csproj @@ -14,7 +14,6 @@ - From 82ef0863ac3599e5fce09c5a2703bb860090755c Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Tue, 2 Jun 2026 12:42:50 +0100 Subject: [PATCH 40/58] wip --- src/Directory.Build.props | 1 + src/Directory.Packages.props | 14 +-- src/Typical.Core/Data/ITextRepository.cs | 2 - src/Typical.Core/Data/Quote.cs | 5 +- src/Typical.Core/ViewModels/MainViewModel.cs | 2 +- .../ViewModels/ResultsViewModel.cs | 8 +- src/Typical.DataAccess/Constants.cs | 38 -------- src/Typical.DataAccess/DapperAot.cs | 1 + src/Typical.DataAccess/IDatabaseMigrator.cs | 18 ++++ .../Migrations/202605230238_AddTestTables.sql | 5 - src/Typical.DataAccess/QuoteDto.cs | 40 ++++++++ .../Sqlite/StatsRepository.cs | 1 + .../Sqlite/TextRepository.cs | 95 +++---------------- .../Typical.DataAccess.csproj | 4 + .../Core/Statistics/GameStatsTests.cs | 1 + 15 files changed, 95 insertions(+), 140 deletions(-) delete mode 100644 src/Typical.DataAccess/Constants.cs create mode 100644 src/Typical.DataAccess/DapperAot.cs create mode 100644 src/Typical.DataAccess/IDatabaseMigrator.cs create mode 100644 src/Typical.DataAccess/QuoteDto.cs diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8ce16d2..eca7ff2 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -8,6 +8,7 @@ embedded $(MSBuildThisFileDirectory)..\.artifacts true + runtime-async=on diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 32eb69f..2dbf637 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -7,6 +7,8 @@ + + @@ -18,14 +20,8 @@ - - + + @@ -53,4 +49,4 @@ - + \ No newline at end of file diff --git a/src/Typical.Core/Data/ITextRepository.cs b/src/Typical.Core/Data/ITextRepository.cs index b11d142..e916805 100644 --- a/src/Typical.Core/Data/ITextRepository.cs +++ b/src/Typical.Core/Data/ITextRepository.cs @@ -4,7 +4,5 @@ public interface ITextRepository { Task GetRandomQuoteAsync(); Task GetQuoteAsync(int currentId); - Task GetQuoteByIdAsync(int id); - Task AddQuotesAsync(IEnumerable quotes); Task HasAnyAsync(); } diff --git a/src/Typical.Core/Data/Quote.cs b/src/Typical.Core/Data/Quote.cs index 150566d..92947a0 100644 --- a/src/Typical.Core/Data/Quote.cs +++ b/src/Typical.Core/Data/Quote.cs @@ -1,3 +1,6 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations.Schema; + namespace Typical.Core.Data; public class Quote @@ -5,7 +8,7 @@ public class Quote public int Id { get; set; } public required string Text { get; set; } public required string Author { get; set; } - public IEnumerable Tags { get; set; } = []; + public List Tags { get; set; } = []; public int WordCount { get; set; } public int CharCount { get; set; } } diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index 8bca381..f936fde 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -67,7 +67,7 @@ private void ShowAbout() public void Receive(TestCompletedMessage message) { - _navigationService.NavigateTo(vm => vm.Initialize(message.Result)); + _navigationService.ShowModal(vm => vm.Initialize(message.Result)); } public async void Receive(TestResetMessage message) diff --git a/src/Typical.Core/ViewModels/ResultsViewModel.cs b/src/Typical.Core/ViewModels/ResultsViewModel.cs index ba1b6c5..997c5d0 100644 --- a/src/Typical.Core/ViewModels/ResultsViewModel.cs +++ b/src/Typical.Core/ViewModels/ResultsViewModel.cs @@ -1,10 +1,16 @@ using CommunityToolkit.Mvvm.ComponentModel; + +using Typical.Core.Interfaces; using Typical.Core.Statistics; namespace Typical.Core.ViewModels; -public class ResultsViewModel : ObservableObject +public class ResultsViewModel : ObservableObject, IModalViewModel { + public bool Result => true; + + public event EventHandler? RequestClose; + public void Initialize(TestResult result) { // TODO: finish implementing display of results diff --git a/src/Typical.DataAccess/Constants.cs b/src/Typical.DataAccess/Constants.cs deleted file mode 100644 index 91de48b..0000000 --- a/src/Typical.DataAccess/Constants.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace Typical.DataAccess; - -public static class LiteDbConstants -{ - static LiteDbConstants() - { - string? dataDir = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - - if (dataDir is null) - { - if (OperatingSystem.IsWindows()) - { - dataDir = Environment.GetEnvironmentVariable("LOCALAPPDATA")!; - } - else if (OperatingSystem.IsLinux()) - { - dataDir = Path.Combine( - Environment.GetEnvironmentVariable("HOME")!, - ".local", - "share" - ); - } - else if (OperatingSystem.IsMacOS()) - { - dataDir = Path.Combine( - Environment.GetEnvironmentVariable("HOME")!, - "Library", - "Application Support" - ); - } - } - DataDirectory = Path.Combine(dataDir!, "typical"); - } - - public static string DataDirectory { get; } - public static string DbFile => Path.Combine(DataDirectory, "typical.db"); - public static string ConnectionString => $"Filename={DbFile}"; -} diff --git a/src/Typical.DataAccess/DapperAot.cs b/src/Typical.DataAccess/DapperAot.cs new file mode 100644 index 0000000..04335f1 --- /dev/null +++ b/src/Typical.DataAccess/DapperAot.cs @@ -0,0 +1 @@ +using Dapper; diff --git a/src/Typical.DataAccess/IDatabaseMigrator.cs b/src/Typical.DataAccess/IDatabaseMigrator.cs new file mode 100644 index 0000000..eb0fddf --- /dev/null +++ b/src/Typical.DataAccess/IDatabaseMigrator.cs @@ -0,0 +1,18 @@ +using System.Data; +using System.Data.Common; +using System.Text.Json; +using Dapper; +using DbUp; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Typical.Core.Data; +using Typical.DataAccess.Sqlite; + +namespace Typical.DataAccess.Sqlite; + +public interface IDatabaseMigrator +{ + Task EnsureDatabaseUpdated(); +} diff --git a/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql index 401cd24..46c7ae1 100644 --- a/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql +++ b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql @@ -6,11 +6,6 @@ CREATE TABLE Tests ( RawWpm REAL, Accuracy REAL, DurationMs INTEGER, - - -- TargetText is now NULLABLE - TargetText TEXT NULL, - - -- New hybrid columns QuoteId INTEGER NULL REFERENCES Quotes(Id), CustomText TEXT NULL ); diff --git a/src/Typical.DataAccess/QuoteDto.cs b/src/Typical.DataAccess/QuoteDto.cs new file mode 100644 index 0000000..23cc270 --- /dev/null +++ b/src/Typical.DataAccess/QuoteDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Dapper; +using Typical.Core.Data; + +namespace Typical.DataAccess; + +[DapperAot] +internal class QuoteDto +{ + public int Id { get; set; } + public required string Text { get; set; } + public required string Author { get; set; } + + // SQLite returns a string, so we read a string. + // [DbValue] maps the SQLite "Tags" column into this property automatically. + [DbValue(Name = "Tags")] + public string? TagsJson { get; set; } + public int WordCount { get; set; } + public int CharCount { get; set; } + + internal Quote ToQuote() + { + return new Quote + { + Id = Id, + Text = Text, + Author = Author, + Tags = + TagsJson?.IsWhiteSpace() == true + ? [] + : JsonSerializer.Deserialize(TagsJson ?? string.Empty, AppJsonContext.Default.ListString) ?? [], + WordCount = WordCount, + CharCount = CharCount, + }; + } +} + +[JsonSerializable(typeof(List))] +internal partial class AppJsonContext : JsonSerializerContext { } diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index 4d947b4..edf5179 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Text; +using Dapper; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; using Typical.Core.Data; diff --git a/src/Typical.DataAccess/Sqlite/TextRepository.cs b/src/Typical.DataAccess/Sqlite/TextRepository.cs index 41ba55e..237482a 100644 --- a/src/Typical.DataAccess/Sqlite/TextRepository.cs +++ b/src/Typical.DataAccess/Sqlite/TextRepository.cs @@ -1,83 +1,42 @@ +using System.Data; +using System.Data.Common; using System.Text.Json; +using Dapper; using DbUp; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Typical.Core.Data; +using Typical.DataAccess.Sqlite; [assembly: DbUpGenerateScripts] +[module: DapperAot(true)] namespace Typical.DataAccess.Sqlite; public class TextRepository(IOptions options) : ITextRepository { - public async Task GetQuoteByIdAsync(int id) - { - await using var connection = await GetOpenConnectionAsync(); - await using var command = connection.CreateCommand(); - command.CommandText = - @"SELECT Id, Text, Author, Tags, WordCount, CharCount FROM Quotes WHERE Id = @id;"; - command.Parameters.AddWithValue("@id", id); - await using var reader = await command.ExecuteReaderAsync(); - if (await reader.ReadAsync()) - { - return MapReaderToQuote(reader); - } - throw new InvalidOperationException($"No quote found with Id {id}."); - } - - private async Task GetOpenConnectionAsync() + private async Task GetOpenConnectionAsync() { var connection = new SqliteConnection(options.Value.GetConnectionString()); await connection.OpenAsync(); return connection; } - public Task AddQuotesAsync(IEnumerable quotes) - { - throw new NotImplementedException(); - } - public async Task GetQuoteAsync(int id) { await using var connection = await GetOpenConnectionAsync(); - await using var command = connection.CreateCommand(); - - command.CommandText = - @" - SELECT Id, Text, Author, Tags, WordCount, CharCount - FROM Quotes - WHERE Id > @id - ORDER BY Id ASC LIMIT 1;"; - command.Parameters.AddWithValue("@id", id); - await using (var reader = await command.ExecuteReaderAsync()) - { - if (await reader.ReadAsync()) - { - return MapReaderToQuote(reader); - } - } - - // Now safe to reuse command - command.CommandText = + string sql = @" SELECT Id, Text, Author, Tags, WordCount, CharCount FROM Quotes ORDER BY Id ASC LIMIT 1;"; - await using (var wrapReader = await command.ExecuteReaderAsync()) - { - if (await wrapReader.ReadAsync()) - { - return MapReaderToQuote(wrapReader); - } - } + var dto = await connection.QueryFirstAsync(sql); - throw new InvalidOperationException( - "No quotes found in the database. Ensure the migration and seeding scripts ran successfully." - ); + return dto.ToQuote(); } public async Task GetRandomQuoteAsync() @@ -85,19 +44,12 @@ public async Task GetRandomQuoteAsync() await using var connection = await GetOpenConnectionAsync(); await connection.OpenAsync(); - await using var command = connection.CreateCommand(); - command.CommandText = + string sql = "SELECT Id, Text, Author, Tags, WordCount, CharCount FROM Quotes ORDER BY RANDOM() LIMIT 1"; - await using var reader = await command.ExecuteReaderAsync(); - if (await reader.ReadAsync()) - { - return MapReaderToQuote(reader); - } + var quote1 = await connection.QueryFirstAsync(sql); - throw new InvalidOperationException( - "No quotes found in the database. Ensure the migration and seeding scripts ran successfully." - ); + return quote1.ToQuote(); } public Task HasAnyAsync() @@ -105,27 +57,4 @@ public Task HasAnyAsync() throw new NotImplementedException(); } - private static Quote MapReaderToQuote(SqliteDataReader reader) - { - var tagsJson = reader.IsDBNull(3) ? null : reader.GetString(3); - - return new Quote - { - Id = reader.GetInt32(0), - Text = reader.GetString(1), - Author = reader.IsDBNull(2) ? "Unknown" : reader.GetString(2), - // AOT-Safe deserialization - Tags = - tagsJson != null - ? JsonSerializer.Deserialize(tagsJson, SeedContext.Default.ListString) ?? [] - : [], - WordCount = reader.GetInt32(4), - CharCount = reader.GetInt32(5), - }; - } -} - -public interface IDatabaseMigrator -{ - Task EnsureDatabaseUpdated(); } diff --git a/src/Typical.DataAccess/Typical.DataAccess.csproj b/src/Typical.DataAccess/Typical.DataAccess.csproj index 04e10bd..b98e89f 100644 --- a/src/Typical.DataAccess/Typical.DataAccess.csproj +++ b/src/Typical.DataAccess/Typical.DataAccess.csproj @@ -1,9 +1,13 @@  true + $(InterceptorsNamespaces);Dapper.AOT + true + + diff --git a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs index 0b3176b..5579041 100644 --- a/src/Typical.Tests/Core/Statistics/GameStatsTests.cs +++ b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs @@ -15,6 +15,7 @@ public async Task CreateSnapshot_CalculatesAccurateWPM_BasedOnTime() fakeTime.Advance(TimeSpan.FromSeconds(12)); stats.Stop(); var snapshot = stats.GetCurrentSnapshot(); + await Assert.That(snapshot.WPM.Value).IsEqualTo(6).Within(0.0001); await Assert.That(snapshot.Accuracy.Value).IsEqualTo(100); } From 89bf6c18af657e4d8ba43410d21e7a80f748f47b Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Tue, 2 Jun 2026 18:07:25 +0100 Subject: [PATCH 41/58] experiment: add Stanza --- src/Directory.Packages.props | 5 +-- src/Typical/Typical.csproj | 1 + src/Typical/UI/Views/TypingView.cs | 53 +++++++++++++++++++++--------- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 2dbf637..bd4844b 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -41,12 +41,13 @@ + - + - \ No newline at end of file + diff --git a/src/Typical/Typical.csproj b/src/Typical/Typical.csproj index 1fa7f4a..1ff2d42 100644 --- a/src/Typical/Typical.csproj +++ b/src/Typical/Typical.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Typical/UI/Views/TypingView.cs b/src/Typical/UI/Views/TypingView.cs index 6ca5110..f90eab5 100644 --- a/src/Typical/UI/Views/TypingView.cs +++ b/src/Typical/UI/Views/TypingView.cs @@ -1,26 +1,34 @@ using System.ComponentModel; using System.Text; + +using Stanza.TerminalGui; + using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; + using Typical.Core.ViewModels; namespace Typical.UI.Views; -public class TypingView : BindableView +public class TypingView : View { + private readonly BindingContext _bindingContext; private readonly TypingArea _typingArea; private readonly Label _sourceLabel; + private bool _disposed; + + public TypingViewModel ViewModel { get; } public TypingView(TypingViewModel viewModel) - : base(viewModel) { CanFocus = true; X = Pos.Center(); Y = Pos.Center(); Width = Dim.Fill(); Height = Dim.Fill(); - +ViewModel = viewModel; + _bindingContext = new BindingContext(); _typingArea = new TypingArea(viewModel) { X = Pos.Center(), @@ -30,7 +38,16 @@ public TypingView(TypingViewModel viewModel) }; _sourceLabel = new Label(); Add(_typingArea); - + _bindingContext.AddBinding(ViewModel.Bind(() => ViewModel.Target, + target => + { + App?.Invoke(() => + { + _typingArea.Refresh(); + _sourceLabel.Text = target?.Source ?? string.Empty; + SetNeedsDraw(); + }); + })); ViewModel.RefreshRequested += OnViewModelRefreshRequested; Initialized += (s, e) => @@ -86,22 +103,26 @@ protected override bool OnKeyDown(Key key) return false; } - protected override void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - App?.Invoke(() => - { - if (e.PropertyName == nameof(ViewModel.Target)) - { - _typingArea.Refresh(); - _sourceLabel.Text = ViewModel.Target.Source; - } - }); - } + // protected void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + // { + // App?.Invoke(() => + // { + // if (e.PropertyName == nameof(ViewModel.Target)) + // { + // _typingArea.Refresh(); + // _sourceLabel.Text = ViewModel.Target.Source; + // } + // }); + + // SetNeedsDraw(); + // } protected override void Dispose(bool disposing) { - if (disposing) + if (disposing && !_disposed) { + _bindingContext.Dispose(); + _disposed = true; ViewModel.RefreshRequested -= OnViewModelRefreshRequested; } From 11e8ae128da54abed2b05f084d949d1f80e5f49b Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Tue, 2 Jun 2026 18:07:33 +0100 Subject: [PATCH 42/58] rm test --- src/Typical.Tests/Data/TextRepositoryTests.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Typical.Tests/Data/TextRepositoryTests.cs b/src/Typical.Tests/Data/TextRepositoryTests.cs index bef3ba1..6d60fca 100644 --- a/src/Typical.Tests/Data/TextRepositoryTests.cs +++ b/src/Typical.Tests/Data/TextRepositoryTests.cs @@ -39,20 +39,20 @@ public async Task GetRandomQuoteAsync_ReturnsSeededQuote() await conn.DisposeAsync(); } - [Test] - public async Task GetQuoteAsync_MapsDBNullAuthor_ToUnknown() - { - var (conn, repo) = await CreateInMemoryRepoAsync(); - // Insert a quote with NULL author - var cmd = conn.CreateCommand(); - cmd.CommandText = "INSERT INTO Quotes (Text, Author) VALUES (@text, NULL);"; - cmd.Parameters.AddWithValue("@text", "Anonymous wisdom"); - await cmd.ExecuteNonQueryAsync(); - // Get the last inserted row - cmd.CommandText = "SELECT last_insert_rowid();"; - var id = (long)await cmd.ExecuteScalarAsync(); - var quote = await repo.GetQuoteByIdAsync((int)id); - await Assert.That(quote.Author).IsEqualTo("Unknown"); - await conn.DisposeAsync(); - } + // [Test] + // public async Task GetQuoteAsync_MapsDBNullAuthor_ToUnknown() + // { + // var (conn, repo) = await CreateInMemoryRepoAsync(); + // // Insert a quote with NULL author + // var cmd = conn.CreateCommand(); + // cmd.CommandText = "INSERT INTO Quotes (Text, Author) VALUES (@text, NULL);"; + // cmd.Parameters.AddWithValue("@text", "Anonymous wisdom"); + // await cmd.ExecuteNonQueryAsync(); + // // Get the last inserted row + // cmd.CommandText = "SELECT last_insert_rowid();"; + // var id = (long)await cmd.ExecuteScalarAsync(); + // var quote = await repo.GetQuoteByIdAsync((int)id); + // await Assert.That(quote.Author).IsEqualTo("Unknown"); + // await conn.DisposeAsync(); + // } } From 53da66e33f299de4bae91b3e478d65237c3fb3f8 Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Tue, 2 Jun 2026 18:07:43 +0100 Subject: [PATCH 43/58] update loom --- .build/instructions.md | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .build/instructions.md diff --git a/.build/instructions.md b/.build/instructions.md new file mode 100644 index 0000000..286699b --- /dev/null +++ b/.build/instructions.md @@ -0,0 +1,61 @@ +# Loom Build Instructions + +## Prerequisites + +- .NET SDK from `global.json` +- Tool manifest in `dotnet-tools.json` + +Install/restore tools: + +```bash +dotnet tool restore +``` + +Required tools and dependent Loom modules: + +| Tool Command | Tool Package | Dependent Module(s) | +| --- | --- | --- | +| `loom` | `loom.build` | CLI entry point used to run all targets/modules | +| `minver` | `minver-cli` | `MinVerModule` | +| `vpk` | `vpk` | `VelopackReleaseModule` | +| `reportgenerator` | `dotnet-reportgenerator-globaltool` | `ReportGeneratorModule` | + +## Setup + +Initialize Loom files: + +```bash +dotnet loom init +``` + +Run tests: + +```bash +dotnet loom test +``` + +Run release pipeline: + +```bash +dotnet loom release +``` + +## Enable NuGet and GitHub Releases + +To allow upload/publishing modules to run, enable the following flags in `.build/loom.json`: + +```json +{ + "workspace": { + "enableNugetUpload": true, + "enableGithubRelease": true + } +} +``` + +Also configure required GitHub secrets: + +- `GITHUB_TOKEN` is the built-in GitHub Actions token (`secrets.GITHUB_TOKEN`). +- Create a repository secret named `NUGET_API_KEY`. + +See release workflow setup in [.github/workflows/release.yml](../.github/workflows/release.yml). From ceaad487f58116ef69594ac7b80228261ee2b7cf Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:44:35 +0100 Subject: [PATCH 44/58] wip: stanza experiment --- .foam/templates/adr-template.md | 19 +++++++++++++++++++ src/Typical/Typical.csproj | 2 +- src/Typical/UI/Views/TypingView.cs | 29 +++++++++++++++-------------- 3 files changed, 35 insertions(+), 15 deletions(-) create mode 100644 .foam/templates/adr-template.md diff --git a/.foam/templates/adr-template.md b/.foam/templates/adr-template.md new file mode 100644 index 0000000..134631d --- /dev/null +++ b/.foam/templates/adr-template.md @@ -0,0 +1,19 @@ +--- +status: proposed +--- + +# ADR 2: MVVM Binding Logic and Lifecycle Management + +## Context + +ENTER HERE + +## Decision + +ENTER HERE + +## Conesquences + +### Pros + +### Cons diff --git a/src/Typical/Typical.csproj b/src/Typical/Typical.csproj index 1ff2d42..e0ceaa7 100644 --- a/src/Typical/Typical.csproj +++ b/src/Typical/Typical.csproj @@ -22,7 +22,6 @@ - @@ -31,6 +30,7 @@ + diff --git a/src/Typical/UI/Views/TypingView.cs b/src/Typical/UI/Views/TypingView.cs index f90eab5..32d4370 100644 --- a/src/Typical/UI/Views/TypingView.cs +++ b/src/Typical/UI/Views/TypingView.cs @@ -1,12 +1,9 @@ using System.ComponentModel; using System.Text; - using Stanza.TerminalGui; - using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; - using Typical.Core.ViewModels; namespace Typical.UI.Views; @@ -27,7 +24,7 @@ public TypingView(TypingViewModel viewModel) Y = Pos.Center(); Width = Dim.Fill(); Height = Dim.Fill(); -ViewModel = viewModel; + ViewModel = viewModel; _bindingContext = new BindingContext(); _typingArea = new TypingArea(viewModel) { @@ -38,16 +35,20 @@ public TypingView(TypingViewModel viewModel) }; _sourceLabel = new Label(); Add(_typingArea); - _bindingContext.AddBinding(ViewModel.Bind(() => ViewModel.Target, - target => - { - App?.Invoke(() => - { - _typingArea.Refresh(); - _sourceLabel.Text = target?.Source ?? string.Empty; - SetNeedsDraw(); - }); - })); + _bindingContext.AddBinding( + ViewModel.Bind( + () => ViewModel.Target, + target => + { + App?.Invoke(() => + { + _typingArea.Refresh(); + _sourceLabel.Text = target?.Source ?? string.Empty; + SetNeedsDraw(); + }); + } + ) + ); ViewModel.RefreshRequested += OnViewModelRefreshRequested; Initialized += (s, e) => From 21b0f367d81051eeacda0832e6f60f1aab1cf00d Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Thu, 4 Jun 2026 22:46:40 +0100 Subject: [PATCH 45/58] wip: stanza experiment --- src/Typical/Typical.csproj | 2 +- src/Typical/UI/Binding/BindingContext.cs | 68 +++--- src/Typical/UI/Binding/BindingExtensions.cs | 238 ++++++++++---------- src/Typical/UI/Binding/DisposableAction.cs | 44 ++-- src/Typical/UI/Views/BindableView.cs | 21 +- src/Typical/UI/Views/HomeView.cs | 1 - src/Typical/UI/Views/MainShell.cs | 11 +- src/Typical/UI/Views/SettingsView.cs | 25 +- src/Typical/UI/Views/StatsView.cs | 43 ++-- src/Typical/UI/Views/TypingView.cs | 39 ++-- 10 files changed, 258 insertions(+), 234 deletions(-) diff --git a/src/Typical/Typical.csproj b/src/Typical/Typical.csproj index e0ceaa7..e394fc3 100644 --- a/src/Typical/Typical.csproj +++ b/src/Typical/Typical.csproj @@ -30,7 +30,7 @@ - + diff --git a/src/Typical/UI/Binding/BindingContext.cs b/src/Typical/UI/Binding/BindingContext.cs index c557f55..35ea098 100644 --- a/src/Typical/UI/Binding/BindingContext.cs +++ b/src/Typical/UI/Binding/BindingContext.cs @@ -1,38 +1,38 @@ -namespace Typical.UI.Binding; +// namespace Typical.UI.Binding; -/// -/// Manages the lifecycle of multiple bindings, providing centralized cleanup. -/// -public class BindingContext : IDisposable -{ - private readonly List _bindings = new(); - private bool _disposed; +// /// +// /// Manages the lifecycle of multiple bindings, providing centralized cleanup. +// /// +// public class BindingContext : IDisposable +// { +// private readonly List _bindings = new(); +// private bool _disposed; - /// - /// Adds a binding to be managed by this context. - /// - public void AddBinding(IDisposable binding) - { - if (_disposed) - throw new ObjectDisposedException(nameof(BindingContext)); +// /// +// /// Adds a binding to be managed by this context. +// /// +// public void AddBinding(IDisposable binding) +// { +// if (_disposed) +// throw new ObjectDisposedException(nameof(BindingContext)); - _bindings.Add(binding); - } +// _bindings.Add(binding); +// } - /// - /// Disposes all managed bindings. - /// - public void Dispose() - { - if (!_disposed) - { - foreach (var binding in _bindings) - { - binding.Dispose(); - } - _bindings.Clear(); - _disposed = true; - GC.SuppressFinalize(this); - } - } -} +// /// +// /// Disposes all managed bindings. +// /// +// public void Dispose() +// { +// if (!_disposed) +// { +// foreach (var binding in _bindings) +// { +// binding.Dispose(); +// } +// _bindings.Clear(); +// _disposed = true; +// GC.SuppressFinalize(this); +// } +// } +// } diff --git a/src/Typical/UI/Binding/BindingExtensions.cs b/src/Typical/UI/Binding/BindingExtensions.cs index 186faf9..81f245a 100644 --- a/src/Typical/UI/Binding/BindingExtensions.cs +++ b/src/Typical/UI/Binding/BindingExtensions.cs @@ -1,134 +1,134 @@ -using System.ComponentModel; -using System.Linq.Expressions; -using System.Runtime.CompilerServices; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; +// using System.ComponentModel; +// using System.Linq.Expressions; +// using System.Runtime.CompilerServices; +// using CommunityToolkit.Mvvm.ComponentModel; +// using CommunityToolkit.Mvvm.Input; +// using Terminal.Gui.ViewBase; +// using Terminal.Gui.Views; -namespace Typical.UI.Binding; +// namespace Typical.UI.Binding; -public static class BindingExtensions -{ - /// - /// Generic One-Way Binding: VM -> UI - /// Works for strings, bools, ints, or custom objects. - /// - public static IDisposable Bind( - this ObservableObject viewModel, - Func propertyExpression, - Action updateUi, - [CallerArgumentExpression(nameof(propertyExpression))] string? expression = null - ) - { - string propertyName = - expression?.Split('.').Last() - ?? throw new ArgumentException("Could not determine property name from expression."); +// public static class BindingExtensions +// { +// /// +// /// Generic One-Way Binding: VM -> UI +// /// Works for strings, bools, ints, or custom objects. +// /// +// public static IDisposable Bind( +// this ObservableObject viewModel, +// Func propertyExpression, +// Action updateUi, +// [CallerArgumentExpression(nameof(propertyExpression))] string? expression = null +// ) +// { +// string propertyName = +// expression?.Split('.').Last() +// ?? throw new ArgumentException("Could not determine property name from expression."); - viewModel.PropertyChanged += Handler; - updateUi(propertyExpression()); +// viewModel.PropertyChanged += Handler; +// updateUi(propertyExpression()); - return new DisposableAction(() => viewModel.PropertyChanged -= Handler); +// return new DisposableAction(() => viewModel.PropertyChanged -= Handler); - void Handler(object? sender, PropertyChangedEventArgs e) - { - if (string.Equals(e.PropertyName, propertyName, StringComparison.Ordinal)) - { - updateUi(propertyExpression()); - } - } - } +// void Handler(object? sender, PropertyChangedEventArgs e) +// { +// if (string.Equals(e.PropertyName, propertyName, StringComparison.Ordinal)) +// { +// updateUi(propertyExpression()); +// } +// } +// } - /// - /// Two-Way String Binding: VM <-> TextField - /// - public static IDisposable BindText( - this ObservableObject viewModel, - View target, - Func getter, - Action? setter = null - ) - { - var vmToUi = viewModel.Bind( - getter, - val => - { - if (target.Text != val) - { - target.Text = val; - target.SetNeedsDraw(); - } - } - ); +// /// +// /// Two-Way String Binding: VM <-> TextField +// /// +// public static IDisposable BindText( +// this ObservableObject viewModel, +// View target, +// Func getter, +// Action? setter = null +// ) +// { +// var vmToUi = viewModel.Bind( +// getter, +// val => +// { +// if (target.Text != val) +// { +// target.Text = val; +// target.SetNeedsDraw(); +// } +// } +// ); - if (setter != null) - { - void OnTextChanged(object? s, EventArgs e) => setter(target.Text); - target.TextChanged += OnTextChanged; +// if (setter != null) +// { +// void OnTextChanged(object? s, EventArgs e) => setter(target.Text); +// target.TextChanged += OnTextChanged; - return new DisposableAction(() => - { - vmToUi.Dispose(); - target.TextChanged -= OnTextChanged; - }); - } +// return new DisposableAction(() => +// { +// vmToUi.Dispose(); +// target.TextChanged -= OnTextChanged; +// }); +// } - return vmToUi; - } +// return vmToUi; +// } - /// - /// Two-Way Boolean Binding: VM <-> CheckBox - /// - public static IDisposable BindChecked( - this ObservableObject viewModel, - CheckBox checkBox, - Func getter, - Action setter - ) - { - var vmToUi = viewModel.Bind( - getter, - val => - { - var newState = val ? CheckState.Checked : CheckState.UnChecked; - if (checkBox.Value != newState) - { - checkBox.Value = newState; - checkBox.SetNeedsDraw(); - } - } - ); +// /// +// /// Two-Way Boolean Binding: VM <-> CheckBox +// /// +// public static IDisposable BindChecked( +// this ObservableObject viewModel, +// CheckBox checkBox, +// Func getter, +// Action setter +// ) +// { +// var vmToUi = viewModel.Bind( +// getter, +// val => +// { +// var newState = val ? CheckState.Checked : CheckState.UnChecked; +// if (checkBox.Value != newState) +// { +// checkBox.Value = newState; +// checkBox.SetNeedsDraw(); +// } +// } +// ); - void OnUiChanged(object? s, EventArgs e) => setter(checkBox.Value == CheckState.Checked); - checkBox.Accepted += OnUiChanged; +// void OnUiChanged(object? s, EventArgs e) => setter(checkBox.Value == CheckState.Checked); +// checkBox.Accepted += OnUiChanged; - return new DisposableAction(() => - { - vmToUi.Dispose(); - checkBox.Accepted -= OnUiChanged; - }); - } +// return new DisposableAction(() => +// { +// vmToUi.Dispose(); +// checkBox.Accepted -= OnUiChanged; +// }); +// } - /// - /// Command: Connects a ViewModel command to a Button. - /// - public static IDisposable BindCommand( - this ObservableObject _, - IRelayCommand command, - Button button - ) - { - void UpdateEnabled(object? s, EventArgs e) => button.Enabled = command.CanExecute(null); - void OnAccept(object? s, EventArgs e) => command.Execute(null); +// /// +// /// Command: Connects a ViewModel command to a Button. +// /// +// public static IDisposable BindCommand( +// this ObservableObject _, +// IRelayCommand command, +// Button button +// ) +// { +// void UpdateEnabled(object? s, EventArgs e) => button.Enabled = command.CanExecute(null); +// void OnAccept(object? s, EventArgs e) => command.Execute(null); - command.CanExecuteChanged += UpdateEnabled; - button.Accepting += OnAccept; - button.Enabled = command.CanExecute(null); +// command.CanExecuteChanged += UpdateEnabled; +// button.Accepting += OnAccept; +// button.Enabled = command.CanExecute(null); - return new DisposableAction(() => - { - command.CanExecuteChanged -= UpdateEnabled; - button.Accepting -= OnAccept; - }); - } -} +// return new DisposableAction(() => +// { +// command.CanExecuteChanged -= UpdateEnabled; +// button.Accepting -= OnAccept; +// }); +// } +// } diff --git a/src/Typical/UI/Binding/DisposableAction.cs b/src/Typical/UI/Binding/DisposableAction.cs index 63a2b71..ee43e29 100644 --- a/src/Typical/UI/Binding/DisposableAction.cs +++ b/src/Typical/UI/Binding/DisposableAction.cs @@ -1,25 +1,25 @@ -namespace Typical.UI.Binding; +// namespace Typical.UI.Binding; -/// -/// A simple disposable action that executes a delegate when disposed. -/// Used for cleaning up event handlers and bindings. -/// -public class DisposableAction : IDisposable -{ - private readonly Action _action; - private bool _disposed; +// /// +// /// A simple disposable action that executes a delegate when disposed. +// /// Used for cleaning up event handlers and bindings. +// /// +// public class DisposableAction : IDisposable +// { +// private readonly Action _action; +// private bool _disposed; - public DisposableAction(Action action) - { - _action = action ?? throw new ArgumentNullException(nameof(action)); - } +// public DisposableAction(Action action) +// { +// _action = action ?? throw new ArgumentNullException(nameof(action)); +// } - public void Dispose() - { - if (!_disposed) - { - _action(); - _disposed = true; - } - } -} +// public void Dispose() +// { +// if (!_disposed) +// { +// _action(); +// _disposed = true; +// } +// } +// } diff --git a/src/Typical/UI/Views/BindableView.cs b/src/Typical/UI/Views/BindableView.cs index 502e256..292df37 100644 --- a/src/Typical/UI/Views/BindableView.cs +++ b/src/Typical/UI/Views/BindableView.cs @@ -1,10 +1,12 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.ComponentModel; + +using Stanza.TerminalGui; + using Terminal.Gui.ViewBase; using Terminal.Gui.Views; using Typical.Core.Interfaces; -using Typical.UI.Binding; namespace Typical.UI.Views; @@ -26,7 +28,6 @@ protected override void Dispose(bool disposing) // BindingContext.Dispose(); _disposed = true; } - var pos = Pos.Absolute(1); base.Dispose(disposing); } } @@ -103,12 +104,12 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } - protected void Bind( - Func getter, - Action updateUi, - [CallerArgumentExpression(nameof(getter))] string expression = default! - ) - { - BindingContext.AddBinding(ViewModel.Bind(getter, updateUi, expression)); - } + // protected void Bind( + // Func getter, + // Action updateUi, + // [CallerArgumentExpression(nameof(getter))] string expression = default! + // ) + // { + // BindingContext.AddBinding(ViewModel.Bind(getter, updateUi, expression)); + // } } diff --git a/src/Typical/UI/Views/HomeView.cs b/src/Typical/UI/Views/HomeView.cs index 0222bbc..5a70ce9 100644 --- a/src/Typical/UI/Views/HomeView.cs +++ b/src/Typical/UI/Views/HomeView.cs @@ -1,6 +1,5 @@ using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -using Typical.UI.Binding; using Typical.Core.ViewModels; namespace Typical.UI.Views; diff --git a/src/Typical/UI/Views/MainShell.cs b/src/Typical/UI/Views/MainShell.cs index 03f298a..dfa3bfa 100644 --- a/src/Typical/UI/Views/MainShell.cs +++ b/src/Typical/UI/Views/MainShell.cs @@ -1,10 +1,12 @@ using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.Extensions.DependencyInjection; + +using Stanza.TerminalGui; + using Terminal.Gui.Drawing; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -using Typical.UI.Binding; using Typical.Core.ViewModels; using Typical.Navigation; @@ -83,12 +85,7 @@ public MainShell(MainViewModel viewModel, IServiceProvider sp) _footerFrame.Add(statsView); Add(_leftSpacer, _rightSpacer, _headerFrame, _contentFrame, _footerFrame); - _bindingContext.AddBinding( - _viewModel.Bind( - () => _viewModel.CurrentPage, - _ => UpdateContent(_viewModel.CurrentPage) - ) - ); + _viewModel.Bind(this, vm => vm.CurrentPage, _ => UpdateContent(_viewModel.CurrentPage)).AddTo(_bindingContext); _viewModel.NavigateToTestViewCommand.Execute(null); diff --git a/src/Typical/UI/Views/SettingsView.cs b/src/Typical/UI/Views/SettingsView.cs index e99d280..62da5aa 100644 --- a/src/Typical/UI/Views/SettingsView.cs +++ b/src/Typical/UI/Views/SettingsView.cs @@ -1,27 +1,42 @@ using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -using Typical.UI.Binding; using Typical.Core.ViewModels; +using Stanza.TerminalGui; namespace Typical.UI.Views; -public class SettingsView : BindableView +public class SettingsView : View { private readonly Button _btnQuoteMode; + private readonly BindingContext _bindingContext; public SettingsView(SettingsViewModel viewModel) - : base(viewModel) { + ViewModel = viewModel; + _bindingContext = new BindingContext(); Width = Dim.Fill(); Height = Dim.Fill(); _btnQuoteMode = new Button { X = Pos.Center(), Text = "Quote" }; Add(_btnQuoteMode); + + ViewModel.QuoteModeCommand.BindCommand(_btnQuoteMode).AddTo(_bindingContext); } - protected override void SetupBindings() + public SettingsViewModel ViewModel { get; } + + protected override void Dispose(bool disposing) { - BindingContext.AddBinding(ViewModel.BindCommand(ViewModel.QuoteModeCommand, _btnQuoteMode)); + if (disposing) + { + _bindingContext.Dispose(); // Cleans up command event subscriptions safely [1] + } + base.Dispose(disposing); } + + // protected override void SetupBindings() + // { + // BindingContext.AddBinding(ViewModel.BindCommand(ViewModel.QuoteModeCommand, _btnQuoteMode)); + // } } diff --git a/src/Typical/UI/Views/StatsView.cs b/src/Typical/UI/Views/StatsView.cs index 0785305..7d0ca7f 100644 --- a/src/Typical/UI/Views/StatsView.cs +++ b/src/Typical/UI/Views/StatsView.cs @@ -1,35 +1,48 @@ +using Stanza.TerminalGui; + using Terminal.Gui.Drawing; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; + using Typical.Core.ViewModels; namespace Typical.UI.Views; -public class StatsView : BindableView +public class StatsView : View { private readonly Label _statsLabel; - + private readonly BindingContext _bindingContext; public StatsView(StatsViewModel viewModel) - : base(viewModel) { + ViewModel = viewModel; Title = nameof(StatsView); BorderStyle = LineStyle.None; Height = 3; Width = Dim.Fill(); + _bindingContext = new BindingContext(); _statsLabel = new Label { X = Pos.Center(), Y = Pos.Center() }; Add(_statsLabel); - } - protected override void SetupBindings() - { - Bind( - () => ViewModel.Stats, - stats => - { - _statsLabel.Text = - $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WPM.Value)} | Acc: {stats.Accuracy.ToString()}"; - SetNeedsDraw(); - } - ); + ViewModel.Bind(this, vm => vm.Stats, stats => + { + _statsLabel.Text = + $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WPM.Value)} | Acc: {stats.Accuracy.ToString()}"; + SetNeedsDraw(); + }).AddTo(_bindingContext); } + + public StatsViewModel ViewModel { get; } + + // protected override void SetupBindings() + // { + // Bind( + // () => ViewModel.Stats, + // stats => + // { + // _statsLabel.Text = + // $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WPM.Value)} | Acc: {stats.Accuracy.ToString()}"; + // SetNeedsDraw(); + // } + // ); + // } } diff --git a/src/Typical/UI/Views/TypingView.cs b/src/Typical/UI/Views/TypingView.cs index 32d4370..8dc93df 100644 --- a/src/Typical/UI/Views/TypingView.cs +++ b/src/Typical/UI/Views/TypingView.cs @@ -1,21 +1,25 @@ using System.ComponentModel; using System.Text; + using Stanza.TerminalGui; + using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; + using Typical.Core.ViewModels; namespace Typical.UI.Views; public class TypingView : View { - private readonly BindingContext _bindingContext; private readonly TypingArea _typingArea; private readonly Label _sourceLabel; private bool _disposed; - public TypingViewModel ViewModel { get; } + public TypingViewModel ViewModel { get; set; } + + public BindingContext BindingContext { get; } public TypingView(TypingViewModel viewModel) { @@ -25,7 +29,7 @@ public TypingView(TypingViewModel viewModel) Width = Dim.Fill(); Height = Dim.Fill(); ViewModel = viewModel; - _bindingContext = new BindingContext(); + BindingContext = new BindingContext(); _typingArea = new TypingArea(viewModel) { X = Pos.Center(), @@ -35,20 +39,14 @@ public TypingView(TypingViewModel viewModel) }; _sourceLabel = new Label(); Add(_typingArea); - _bindingContext.AddBinding( - ViewModel.Bind( - () => ViewModel.Target, - target => - { - App?.Invoke(() => - { - _typingArea.Refresh(); - _sourceLabel.Text = target?.Source ?? string.Empty; - SetNeedsDraw(); - }); - } - ) - ); + + ViewModel.Bind(this, vm => vm.Target, target => + { + _typingArea.Refresh(); + _sourceLabel.Text = target?.Source ?? string.Empty; + SetNeedsDraw(); + }).AddTo(BindingContext); + ViewModel.RefreshRequested += OnViewModelRefreshRequested; Initialized += (s, e) => @@ -122,9 +120,9 @@ protected override void Dispose(bool disposing) { if (disposing && !_disposed) { - _bindingContext.Dispose(); + BindingContext.Dispose(); _disposed = true; - ViewModel.RefreshRequested -= OnViewModelRefreshRequested; + ViewModel?.RefreshRequested -= OnViewModelRefreshRequested; } base.Dispose(disposing); @@ -134,7 +132,8 @@ private async Task InitializeViewAsync() { try { - await ViewModel.InitializeAsync(); + + await ViewModel.InitializeAsync() ; _typingArea.Refresh(); } catch (Exception ex) From 4051a8f9236744e7bc502949b4e856a3aef4548a Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:50:53 +0100 Subject: [PATCH 46/58] chore: csharpier format --- src/Directory.Packages.props | 10 +- .../ViewModels/ResultsViewModel.cs | 1 - src/Typical.Core/ViewModels/StatsViewModel.cs | 5 +- src/Typical.DataAccess/QuoteDto.cs | 5 +- .../Sqlite/TextRepository.cs | 1 - src/Typical.Tests/UI/BindingTests.cs | 230 +++++++++--------- src/Typical/Program.cs | 2 +- src/Typical/UI/Views/BindableView.cs | 2 - src/Typical/UI/Views/MainShell.cs | 6 +- src/Typical/UI/Views/SettingsView.cs | 4 +- src/Typical/UI/Views/StatsView.cs | 21 +- src/Typical/UI/Views/TypingView.cs | 24 +- 12 files changed, 163 insertions(+), 148 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index bd4844b..03349ba 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -20,8 +20,14 @@ - - + + diff --git a/src/Typical.Core/ViewModels/ResultsViewModel.cs b/src/Typical.Core/ViewModels/ResultsViewModel.cs index 997c5d0..4f1d6eb 100644 --- a/src/Typical.Core/ViewModels/ResultsViewModel.cs +++ b/src/Typical.Core/ViewModels/ResultsViewModel.cs @@ -1,5 +1,4 @@ using CommunityToolkit.Mvvm.ComponentModel; - using Typical.Core.Interfaces; using Typical.Core.Statistics; diff --git a/src/Typical.Core/ViewModels/StatsViewModel.cs b/src/Typical.Core/ViewModels/StatsViewModel.cs index a923859..ab19ff8 100644 --- a/src/Typical.Core/ViewModels/StatsViewModel.cs +++ b/src/Typical.Core/ViewModels/StatsViewModel.cs @@ -15,7 +15,10 @@ public partial class StatsViewModel : ObservableObject, IRecipient(this, (r, m) => r.Receive(m)); + _messenger.Register( + this, + (r, m) => r.Receive(m) + ); } public void Receive(TestSessionUpdatedMessage message) diff --git a/src/Typical.DataAccess/QuoteDto.cs b/src/Typical.DataAccess/QuoteDto.cs index 23cc270..8f2738d 100644 --- a/src/Typical.DataAccess/QuoteDto.cs +++ b/src/Typical.DataAccess/QuoteDto.cs @@ -29,7 +29,10 @@ internal Quote ToQuote() Tags = TagsJson?.IsWhiteSpace() == true ? [] - : JsonSerializer.Deserialize(TagsJson ?? string.Empty, AppJsonContext.Default.ListString) ?? [], + : JsonSerializer.Deserialize( + TagsJson ?? string.Empty, + AppJsonContext.Default.ListString + ) ?? [], WordCount = WordCount, CharCount = CharCount, }; diff --git a/src/Typical.DataAccess/Sqlite/TextRepository.cs b/src/Typical.DataAccess/Sqlite/TextRepository.cs index 237482a..ca8ee42 100644 --- a/src/Typical.DataAccess/Sqlite/TextRepository.cs +++ b/src/Typical.DataAccess/Sqlite/TextRepository.cs @@ -56,5 +56,4 @@ public Task HasAnyAsync() { throw new NotImplementedException(); } - } diff --git a/src/Typical.Tests/UI/BindingTests.cs b/src/Typical.Tests/UI/BindingTests.cs index c44d5b9..5f0ec33 100644 --- a/src/Typical.Tests/UI/BindingTests.cs +++ b/src/Typical.Tests/UI/BindingTests.cs @@ -1,115 +1,115 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Terminal.Gui.Input; -using Terminal.Gui.Views; -using Typical.UI.Binding; - -namespace Typical.Tests.UI; - -public partial class BindingTests -{ - private partial class FakeViewModel : ObservableObject - { - [ObservableProperty] - public partial string Name { get; set; } = string.Empty; - - [ObservableProperty] - public partial int Score { get; set; } - - [RelayCommand] - private void Save() => SaveCalledCount++; - - public int SaveCalledCount { get; set; } - } - - [Test] - public async Task Bind_OneWay_UpdatesUiOnPropertyChange() - { - // Arrange - var vm = new FakeViewModel { Name = "Initial" }; - var uiValue = ""; - - // Act - using var binding = vm.Bind(() => vm.Name, val => uiValue = val); - - // Assert - Initial Value (Bind fires immediately) - await Assert.That(uiValue).IsEqualTo("Initial"); - - // Act - Change VM - vm.Name = "Updated"; - - // Assert - UI Updated - await Assert.That(uiValue).IsEqualTo("Updated"); - } - - [Test] - public async Task Bind_OneWay_DoesNotUpdateAfterDispose() - { - // Arrange - var vm = new FakeViewModel { Name = "Initial" }; - var uiValue = ""; - var binding = vm.Bind(() => vm.Name, val => uiValue = val); - - // Act - binding.Dispose(); - vm.Name = "ChangesAfterDispose"; - - // Assert - await Assert.That(uiValue).IsEqualTo("Initial"); - } - - [Test] - public async Task BindText_TwoWay_UpdatesVmOnUiChange() - { - // Arrange - var vm = new FakeViewModel { Name = "VM" }; - var label = new Label { Text = "UI" }; - - // Act - using var binding = vm.BindText(label, () => vm.Name, val => vm.Name = val); - - // Simulate UI change - label.Text = "ChangedInUI"; - - // Assert - await Assert.That(vm.Name).IsEqualTo("ChangedInUI"); - } - - [Test] - public async Task BindCommand_ExecutesRelayCommandOnButtonAccept() - { - // Arrange - var vm = new FakeViewModel(); - var button = new Button(); - - // Act - using var binding = vm.BindCommand(vm.SaveCommand, button); - - // Simulate Button Accept/Click - button.InvokeCommand(Command.Accept); - - // Assert - await Assert.That(vm.SaveCalledCount).IsEqualTo(1); - } - - [Test] - public async Task BindingContext_Dispose_CleansUpMultipleBindings() - { - // Arrange - var ctx = new BindingContext(); - var vm = new FakeViewModel { Name = "Initial" }; - var uiValue1 = ""; - var uiValue2 = ""; - - ctx.AddBinding(vm.Bind(() => vm.Name, val => uiValue1 = val)); - ctx.AddBinding(vm.Bind(() => vm.Name, val => uiValue2 = val)); - - // Act - ctx.Dispose(); - vm.Name = "NewValue"; - - // Assert - await Assert.That(uiValue1).IsEqualTo("Initial"); - await Assert.That(uiValue2).IsEqualTo("Initial"); - } -} +// using CommunityToolkit.Mvvm.ComponentModel; +// using CommunityToolkit.Mvvm.Input; +// using Terminal.Gui.Input; +// using Terminal.Gui.Views; +// using Typical.UI.Binding; + +// namespace Typical.Tests.UI; + +// public partial class BindingTests +// { +// private partial class FakeViewModel : ObservableObject +// { +// [ObservableProperty] +// public partial string Name { get; set; } = string.Empty; + +// [ObservableProperty] +// public partial int Score { get; set; } + +// [RelayCommand] +// private void Save() => SaveCalledCount++; + +// public int SaveCalledCount { get; set; } +// } + +// [Test] +// public async Task Bind_OneWay_UpdatesUiOnPropertyChange() +// { +// // Arrange +// var vm = new FakeViewModel { Name = "Initial" }; +// var uiValue = ""; + +// // Act +// using var binding = vm.Bind(() => vm.Name, val => uiValue = val); + +// // Assert - Initial Value (Bind fires immediately) +// await Assert.That(uiValue).IsEqualTo("Initial"); + +// // Act - Change VM +// vm.Name = "Updated"; + +// // Assert - UI Updated +// await Assert.That(uiValue).IsEqualTo("Updated"); +// } + +// [Test] +// public async Task Bind_OneWay_DoesNotUpdateAfterDispose() +// { +// // Arrange +// var vm = new FakeViewModel { Name = "Initial" }; +// var uiValue = ""; +// var binding = vm.Bind(() => vm.Name, val => uiValue = val); + +// // Act +// binding.Dispose(); +// vm.Name = "ChangesAfterDispose"; + +// // Assert +// await Assert.That(uiValue).IsEqualTo("Initial"); +// } + +// [Test] +// public async Task BindText_TwoWay_UpdatesVmOnUiChange() +// { +// // Arrange +// var vm = new FakeViewModel { Name = "VM" }; +// var label = new Label { Text = "UI" }; + +// // Act +// using var binding = vm.BindText(label, () => vm.Name, val => vm.Name = val); + +// // Simulate UI change +// label.Text = "ChangedInUI"; + +// // Assert +// await Assert.That(vm.Name).IsEqualTo("ChangedInUI"); +// } + +// [Test] +// public async Task BindCommand_ExecutesRelayCommandOnButtonAccept() +// { +// // Arrange +// var vm = new FakeViewModel(); +// var button = new Button(); + +// // Act +// using var binding = vm.BindCommand(vm.SaveCommand, button); + +// // Simulate Button Accept/Click +// button.InvokeCommand(Command.Accept); + +// // Assert +// await Assert.That(vm.SaveCalledCount).IsEqualTo(1); +// } + +// [Test] +// public async Task BindingContext_Dispose_CleansUpMultipleBindings() +// { +// // Arrange +// var ctx = new BindingContext(); +// var vm = new FakeViewModel { Name = "Initial" }; +// var uiValue1 = ""; +// var uiValue2 = ""; + +// ctx.AddBinding(vm.Bind(() => vm.Name, val => uiValue1 = val)); +// ctx.AddBinding(vm.Bind(() => vm.Name, val => uiValue2 = val)); + +// // Act +// ctx.Dispose(); +// vm.Name = "NewValue"; + +// // Assert +// await Assert.That(uiValue1).IsEqualTo("Initial"); +// await Assert.That(uiValue2).IsEqualTo("Initial"); +// } +// } diff --git a/src/Typical/Program.cs b/src/Typical/Program.cs index b308fe8..838d2b0 100644 --- a/src/Typical/Program.cs +++ b/src/Typical/Program.cs @@ -1,13 +1,13 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Typical.Infrastructure; using Serilog; using Terminal.Gui.App; using Typical.Configuration; using Typical.Core.Services; using Typical.DataAccess; using Typical.DataAccess.Sqlite; +using Typical.Infrastructure; using Typical.Services; using Typical.UI.Views; using Velopack; diff --git a/src/Typical/UI/Views/BindableView.cs b/src/Typical/UI/Views/BindableView.cs index 292df37..6bbcf49 100644 --- a/src/Typical/UI/Views/BindableView.cs +++ b/src/Typical/UI/Views/BindableView.cs @@ -1,9 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.ComponentModel; - using Stanza.TerminalGui; - using Terminal.Gui.ViewBase; using Terminal.Gui.Views; using Typical.Core.Interfaces; diff --git a/src/Typical/UI/Views/MainShell.cs b/src/Typical/UI/Views/MainShell.cs index dfa3bfa..1e09731 100644 --- a/src/Typical/UI/Views/MainShell.cs +++ b/src/Typical/UI/Views/MainShell.cs @@ -1,8 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.Extensions.DependencyInjection; - using Stanza.TerminalGui; - using Terminal.Gui.Drawing; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; @@ -85,7 +83,9 @@ public MainShell(MainViewModel viewModel, IServiceProvider sp) _footerFrame.Add(statsView); Add(_leftSpacer, _rightSpacer, _headerFrame, _contentFrame, _footerFrame); - _viewModel.Bind(this, vm => vm.CurrentPage, _ => UpdateContent(_viewModel.CurrentPage)).AddTo(_bindingContext); + _viewModel + .Bind(this, vm => vm.CurrentPage, _ => UpdateContent(_viewModel.CurrentPage)) + .AddTo(_bindingContext); _viewModel.NavigateToTestViewCommand.Execute(null); diff --git a/src/Typical/UI/Views/SettingsView.cs b/src/Typical/UI/Views/SettingsView.cs index 62da5aa..a6a846e 100644 --- a/src/Typical/UI/Views/SettingsView.cs +++ b/src/Typical/UI/Views/SettingsView.cs @@ -1,7 +1,7 @@ +using Stanza.TerminalGui; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; using Typical.Core.ViewModels; -using Stanza.TerminalGui; namespace Typical.UI.Views; @@ -26,7 +26,7 @@ public SettingsView(SettingsViewModel viewModel) public SettingsViewModel ViewModel { get; } - protected override void Dispose(bool disposing) + protected override void Dispose(bool disposing) { if (disposing) { diff --git a/src/Typical/UI/Views/StatsView.cs b/src/Typical/UI/Views/StatsView.cs index 7d0ca7f..cb8c393 100644 --- a/src/Typical/UI/Views/StatsView.cs +++ b/src/Typical/UI/Views/StatsView.cs @@ -1,9 +1,7 @@ using Stanza.TerminalGui; - using Terminal.Gui.Drawing; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; - using Typical.Core.ViewModels; namespace Typical.UI.Views; @@ -12,6 +10,7 @@ public class StatsView : View { private readonly Label _statsLabel; private readonly BindingContext _bindingContext; + public StatsView(StatsViewModel viewModel) { ViewModel = viewModel; @@ -23,12 +22,18 @@ public StatsView(StatsViewModel viewModel) _statsLabel = new Label { X = Pos.Center(), Y = Pos.Center() }; Add(_statsLabel); - ViewModel.Bind(this, vm => vm.Stats, stats => - { - _statsLabel.Text = - $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WPM.Value)} | Acc: {stats.Accuracy.ToString()}"; - SetNeedsDraw(); - }).AddTo(_bindingContext); + ViewModel + .Bind( + this, + vm => vm.Stats, + stats => + { + _statsLabel.Text = + $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WPM.Value)} | Acc: {stats.Accuracy.ToString()}"; + SetNeedsDraw(); + } + ) + .AddTo(_bindingContext); } public StatsViewModel ViewModel { get; } diff --git a/src/Typical/UI/Views/TypingView.cs b/src/Typical/UI/Views/TypingView.cs index 8dc93df..db2448f 100644 --- a/src/Typical/UI/Views/TypingView.cs +++ b/src/Typical/UI/Views/TypingView.cs @@ -1,12 +1,9 @@ using System.ComponentModel; using System.Text; - using Stanza.TerminalGui; - using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; - using Typical.Core.ViewModels; namespace Typical.UI.Views; @@ -40,12 +37,18 @@ public TypingView(TypingViewModel viewModel) _sourceLabel = new Label(); Add(_typingArea); - ViewModel.Bind(this, vm => vm.Target, target => - { - _typingArea.Refresh(); - _sourceLabel.Text = target?.Source ?? string.Empty; - SetNeedsDraw(); - }).AddTo(BindingContext); + ViewModel + .Bind( + this, + vm => vm.Target, + target => + { + _typingArea.Refresh(); + _sourceLabel.Text = target?.Source ?? string.Empty; + SetNeedsDraw(); + } + ) + .AddTo(BindingContext); ViewModel.RefreshRequested += OnViewModelRefreshRequested; @@ -132,8 +135,7 @@ private async Task InitializeViewAsync() { try { - - await ViewModel.InitializeAsync() ; + await ViewModel.InitializeAsync(); _typingArea.Refresh(); } catch (Exception ex) From a2a0e609b30218b4b92063dbd82531513bb04563 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:05:58 +0100 Subject: [PATCH 47/58] wip: Stanza.TerminalGui 0.1.0-preview.3 --- nuget.config | 3 +- src/Directory.Packages.props | 2 +- src/Typical.Core/ViewModels/MainViewModel.cs | 5 +-- src/Typical/Typical.csproj | 2 +- src/Typical/UI/Views/TypingView.cs | 43 +++----------------- 5 files changed, 11 insertions(+), 44 deletions(-) diff --git a/nuget.config b/nuget.config index 6ce9759..f8333c2 100644 --- a/nuget.config +++ b/nuget.config @@ -1,8 +1,9 @@ - + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 03349ba..c9bb1a7 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -47,7 +47,7 @@ - + diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index f936fde..f75f627 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -8,10 +8,7 @@ namespace Typical.Core.ViewModels; -public sealed partial class MainViewModel - : ObservableObject, - IRecipient, - IRecipient +public sealed partial class MainViewModel : ObservableObject { private readonly INavigationService _navigationService; private readonly IDialogService _dialogService; diff --git a/src/Typical/Typical.csproj b/src/Typical/Typical.csproj index e394fc3..1ff2d42 100644 --- a/src/Typical/Typical.csproj +++ b/src/Typical/Typical.csproj @@ -22,6 +22,7 @@ + @@ -30,7 +31,6 @@ - diff --git a/src/Typical/UI/Views/TypingView.cs b/src/Typical/UI/Views/TypingView.cs index db2448f..85153d9 100644 --- a/src/Typical/UI/Views/TypingView.cs +++ b/src/Typical/UI/Views/TypingView.cs @@ -1,4 +1,3 @@ -using System.ComponentModel; using System.Text; using Stanza.TerminalGui; using Terminal.Gui.Input; @@ -8,15 +7,11 @@ namespace Typical.UI.Views; -public class TypingView : View +[StanzaView] +public partial class TypingView : View { private readonly TypingArea _typingArea; private readonly Label _sourceLabel; - private bool _disposed; - - public TypingViewModel ViewModel { get; set; } - - public BindingContext BindingContext { get; } public TypingView(TypingViewModel viewModel) { @@ -26,7 +21,7 @@ public TypingView(TypingViewModel viewModel) Width = Dim.Fill(); Height = Dim.Fill(); ViewModel = viewModel; - BindingContext = new BindingContext(); + _bindingContext = new BindingContext(); _typingArea = new TypingArea(viewModel) { X = Pos.Center(), @@ -65,7 +60,7 @@ public TypingView(TypingViewModel viewModel) private void OnViewModelRefreshRequested(object? sender, EventArgs e) { - App?.Invoke(() => ViewModel.RefreshState()); + App?.Invoke(() => ViewModel?.RefreshState()); } protected override void OnSubViewsLaidOut(LayoutEventArgs args) @@ -92,7 +87,7 @@ protected override bool OnKeyDown(Key key) { try { - ViewModel.ProcessInput(key.AsGrapheme, isBackspace); + ViewModel?.ProcessInput(key.AsGrapheme, isBackspace); } catch (Exception ex) { @@ -105,37 +100,11 @@ protected override bool OnKeyDown(Key key) return false; } - // protected void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) - // { - // App?.Invoke(() => - // { - // if (e.PropertyName == nameof(ViewModel.Target)) - // { - // _typingArea.Refresh(); - // _sourceLabel.Text = ViewModel.Target.Source; - // } - // }); - - // SetNeedsDraw(); - // } - - protected override void Dispose(bool disposing) - { - if (disposing && !_disposed) - { - BindingContext.Dispose(); - _disposed = true; - ViewModel?.RefreshRequested -= OnViewModelRefreshRequested; - } - - base.Dispose(disposing); - } - private async Task InitializeViewAsync() { try { - await ViewModel.InitializeAsync(); + await (ViewModel?.InitializeAsync() ?? Task.CompletedTask); _typingArea.Refresh(); } catch (Exception ex) From 6726c9fe7325b939562925eca2ee98cf9eebfd43 Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Wed, 10 Jun 2026 17:24:53 +0100 Subject: [PATCH 48/58] adopt stanza --- .vscode/settings.json | 2 +- nuget.config | 1 - src/Directory.Packages.props | 2 +- .../Services/ServiceExtensions.cs | 2 +- src/Typical.Core/Statistics/TestSnapshot.cs | 1 - src/Typical.Core/ViewModels/HomeViewModel.cs | 34 ----- src/Typical.Core/ViewModels/MainViewModel.cs | 4 +- .../ViewModels/SettingsViewModel.cs | 4 +- src/Typical.Core/ViewModels/StatsViewModel.cs | 5 + .../ViewModels/TypingViewModel.cs | 23 +-- .../UI/NavigationServiceTests.cs | 12 +- src/Typical/Navigation/ViewLocator.cs | 1 - src/Typical/Program.cs | 2 + src/Typical/Services/NavigationService.cs | 58 ++++++-- src/Typical/Services/ServiceExtensions.cs | 6 +- src/Typical/UI/Binding/BindingContext.cs | 38 ----- src/Typical/UI/Binding/BindingExtensions.cs | 134 ------------------ src/Typical/UI/Binding/DisposableAction.cs | 25 ---- src/Typical/UI/Views/BindableView.cs | 113 --------------- src/Typical/UI/Views/HomeView.cs | 22 --- src/Typical/UI/Views/MainShell.cs | 31 ++-- src/Typical/UI/Views/ResultsView.cs | 8 +- src/Typical/UI/Views/SettingsView.cs | 27 +--- src/Typical/UI/Views/StatsView.cs | 40 ++---- src/Typical/UI/Views/TypingView.cs | 25 ++-- 25 files changed, 124 insertions(+), 496 deletions(-) delete mode 100644 src/Typical.Core/ViewModels/HomeViewModel.cs delete mode 100644 src/Typical/UI/Binding/BindingContext.cs delete mode 100644 src/Typical/UI/Binding/BindingExtensions.cs delete mode 100644 src/Typical/UI/Binding/DisposableAction.cs delete mode 100644 src/Typical/UI/Views/BindableView.cs delete mode 100644 src/Typical/UI/Views/HomeView.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index 6e0a174..8002fe7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { "[csharp]": { - "editor.defaultFormatter": "csharpier.csharpier-vscode" + "editor.defaultFormatter": "ms-dotnettools.csharp" } } diff --git a/nuget.config b/nuget.config index f8333c2..2805dff 100644 --- a/nuget.config +++ b/nuget.config @@ -3,7 +3,6 @@ - diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index c9bb1a7..3606863 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -47,7 +47,7 @@ - + diff --git a/src/Typical.Core/Services/ServiceExtensions.cs b/src/Typical.Core/Services/ServiceExtensions.cs index 3c95d54..6a8c4fb 100644 --- a/src/Typical.Core/Services/ServiceExtensions.cs +++ b/src/Typical.Core/Services/ServiceExtensions.cs @@ -16,7 +16,7 @@ public static void AddCoreServices(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddTransient(); - services.AddTransient(); + //services.AddTransient(); services.AddSingleton(); services.AddSingleton(); } diff --git a/src/Typical.Core/Statistics/TestSnapshot.cs b/src/Typical.Core/Statistics/TestSnapshot.cs index bf21f8e..c6fc79c 100644 --- a/src/Typical.Core/Statistics/TestSnapshot.cs +++ b/src/Typical.Core/Statistics/TestSnapshot.cs @@ -22,7 +22,6 @@ public static TestSnapshot Create(TestMetrics chars, TimeSpan elapsed) chars, elapsed ); - Debug.WriteLine(snapshot); return snapshot; } diff --git a/src/Typical.Core/ViewModels/HomeViewModel.cs b/src/Typical.Core/ViewModels/HomeViewModel.cs deleted file mode 100644 index 46d19a8..0000000 --- a/src/Typical.Core/ViewModels/HomeViewModel.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Microsoft.Extensions.Logging; -using Typical.Core.Interfaces; - -namespace Typical.Core.ViewModels; - -public sealed partial class HomeViewModel : ObservableObject, INavigatableView -{ - private readonly INavigationService _navService; - private readonly ILogger _logger; - - public HomeViewModel(INavigationService navigationService, ILogger logger) - { - _navService = navigationService; - _logger = logger; - } - - [ObservableProperty] - private string _welcomeMessage = "Welcome to the Dashboard!"; - - [RelayCommand] - private void NavigateSettings() => _navService.NavigateTo(); - - public void OnNavigatedTo() - { - _logger.LogInformation($"Navigated to {nameof(HomeViewModel)}"); - } - - public void OnNavigatedFrom() - { - _logger.LogInformation($"Navigated from {nameof(HomeViewModel)}"); - } -} diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index f75f627..b6b8622 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -50,8 +50,8 @@ IMessenger messenger [RelayCommand] private void NavigateToTestView() => _navigationService.NavigateTo(); - [RelayCommand] - private void NavigateHome() => _navigationService.NavigateTo(); + //[RelayCommand] + //private void NavigateHome() => _navigationService.NavigateTo(); [RelayCommand] private void NavigateSettings() => _navigationService.NavigateTo(); diff --git a/src/Typical.Core/ViewModels/SettingsViewModel.cs b/src/Typical.Core/ViewModels/SettingsViewModel.cs index 1bbf49f..76f55d4 100644 --- a/src/Typical.Core/ViewModels/SettingsViewModel.cs +++ b/src/Typical.Core/ViewModels/SettingsViewModel.cs @@ -37,6 +37,6 @@ private void QuoteMode() _messenger.Send(message); } - [RelayCommand] - private void Cancel() => _navService.NavigateTo(); + //[RelayCommand] + //private void Cancel() => _navService.NavigateTo(); } diff --git a/src/Typical.Core/ViewModels/StatsViewModel.cs b/src/Typical.Core/ViewModels/StatsViewModel.cs index ab19ff8..6c47e75 100644 --- a/src/Typical.Core/ViewModels/StatsViewModel.cs +++ b/src/Typical.Core/ViewModels/StatsViewModel.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; + using Typical.Core.Events; using Typical.Core.Statistics; @@ -24,5 +25,9 @@ public StatsViewModel(IMessenger messenger) public void Receive(TestSessionUpdatedMessage message) { Stats = message.Snapshot; + StatsLabel = $"Elapsed: {Stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(Stats.WPM.Value)} | Acc: {Stats.Accuracy.ToString()}"; } + + [ObservableProperty] + public partial string StatsLabel { get; set; } = string.Empty; } diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index 6195e08..a779a70 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -21,7 +21,6 @@ public partial class TypingViewModel : ObservableObject, INavigatableView private readonly IMessenger _messenger; private readonly Timer _refreshTimer; private bool _isFinishing; - public event EventHandler? RefreshRequested; [ObservableProperty] public required partial TextSample Target { get; set; } = TextSample.Empty; @@ -65,8 +64,6 @@ public async void ProcessInput(string c, bool isBackspace) UpdateState(); } - public void RefreshState() => UpdateState(); - /// /// Synchronizes the Engine state with ViewModel properties. /// This triggers PropertyChanged notifications for the View. @@ -91,14 +88,20 @@ public void OnNavigatedFrom() private void OnRefreshTimerElapsed(object? sender, ElapsedEventArgs e) { - if (_Test.IsOver) + try { - return; - } + if (_Test.IsOver) + { + return; + } - _Test.Stats.SampleSnapshot(); - - RefreshRequested?.Invoke(this, EventArgs.Empty); + _Test.Stats.SampleSnapshot(); + UpdateState(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in refresh timer callback"); + } } public async Task InitializeAsync(TextSample? textSample = null) @@ -121,6 +124,8 @@ private async Task HandleTestFinished(TestResult result) try { + _refreshTimer.Stop(); + await Task.Delay(100); await _statsRepository.SaveTestResultAsync(result); diff --git a/src/Typical.Tests/UI/NavigationServiceTests.cs b/src/Typical.Tests/UI/NavigationServiceTests.cs index bfd236f..936c3e6 100644 --- a/src/Typical.Tests/UI/NavigationServiceTests.cs +++ b/src/Typical.Tests/UI/NavigationServiceTests.cs @@ -45,22 +45,20 @@ public void Setup() TimeProvider.System )); - services.AddSingleton>(NullLogger.Instance); services.AddSingleton>(NullLogger.Instance); services.AddSingleton>(NullLogger.Instance); - services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddSingleton(sp => new NavigationService( sp, null!, - sp.GetRequiredService() + sp.GetRequiredService(), + NullLogger.Instance )); _serviceProvider = services.BuildServiceProvider(); @@ -85,11 +83,11 @@ public async Task NavigateTo_ShouldBroadcastNavigationChangedMessage() try { // Act - _navigationService.NavigateTo(); + _navigationService.NavigateTo(); // Assert await Assert.That(receivedMessage).IsNotNull(); - await Assert.That(receivedMessage!.Value).IsTypeOf(); + await Assert.That(receivedMessage!.Value).IsTypeOf(); } finally { @@ -110,7 +108,7 @@ public async Task NavigateTo_ShouldUpdateCurrentViewModel() [Test] public async Task ViewLocator_MapsAllNavigatableViewModels() { - var coreAssembly = typeof(HomeViewModel).Assembly; + var coreAssembly = typeof(SettingsViewModel).Assembly; var navigatableTypes = coreAssembly .GetTypes() diff --git a/src/Typical/Navigation/ViewLocator.cs b/src/Typical/Navigation/ViewLocator.cs index d2d654c..737ae25 100644 --- a/src/Typical/Navigation/ViewLocator.cs +++ b/src/Typical/Navigation/ViewLocator.cs @@ -11,7 +11,6 @@ public static class ViewLocator public static View GetView(IServiceProvider sp, object viewModel) => viewModel switch { - HomeViewModel => sp.GetRequiredService(), SettingsViewModel => sp.GetRequiredService(), TypingViewModel => sp.GetRequiredService(), ResultsViewModel => sp.GetRequiredService(), diff --git a/src/Typical/Program.cs b/src/Typical/Program.cs index 838d2b0..679e5bf 100644 --- a/src/Typical/Program.cs +++ b/src/Typical/Program.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Serilog; +using Stanza.TerminalGui; using Terminal.Gui.App; using Typical.Configuration; using Typical.Core.Services; @@ -52,6 +53,7 @@ [RequiresDynamicCode("Calls Terminal.Gui.Application.Init(IDriver, String)")] static async Task Run(IHost host) { + host.UseStanzaLogging(); var migrator = host.Services.GetRequiredService(); await migrator.EnsureDatabaseUpdated(); diff --git a/src/Typical/Services/NavigationService.cs b/src/Typical/Services/NavigationService.cs index 83b4aef..b4ab275 100644 --- a/src/Typical/Services/NavigationService.cs +++ b/src/Typical/Services/NavigationService.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Terminal.Gui.App; using Terminal.Gui.Views; using Typical.Core.Events; @@ -14,12 +15,19 @@ public class NavigationService : ObservableObject, INavigationService private readonly IServiceProvider _services; private readonly IApplication _app; private readonly IMessenger _messenger; - - public NavigationService(IServiceProvider services, IApplication app, IMessenger messenger) + private readonly ILogger _logger; + + public NavigationService( + IServiceProvider services, + IApplication app, + IMessenger messenger, + ILogger logger + ) { _services = services; _app = app; _messenger = messenger; + _logger = logger; } private ObservableObject? _currentViewModel; @@ -64,22 +72,44 @@ public void NavigateTo(Action configure) configure?.Invoke(vm); var view = ViewLocator.GetView(_services, vm); - if (view is IRunnable runnable) + EventHandler? handler = null; + handler = (s, e) => _app.RequestStop(); + vm.RequestClose += handler; + + try { - EventHandler? handler = null; - handler = (s, e) => + if (view is Dialog dialog) + { + _logger.LogInformation( + "Showing modal dialog directly for {ViewModelType}", + typeof(TViewModel).Name + ); + _app.Run(dialog); + } + else if (view is IRunnable runnable) + { + _logger.LogInformation( + "Showing runnable modal view for {ViewModelType}: {ViewType}", + typeof(TViewModel).Name, + view.GetType().Name + ); + _app.Run(runnable); + } + else { - _app.RequestStop(); - vm.RequestClose -= handler; - }; - vm.RequestClose += handler; - _app.Run(runnable); + _logger.LogInformation( + "Wrapping non-runnable modal view for {ViewModelType}: {ViewType}", + typeof(TViewModel).Name, + view.GetType().Name + ); + var host = new Dialog { Title = "Modal Host" }; + host.Add(view); + _app.Run(host); + } } - else + finally { - var host = new Dialog { Title = "Modal Host" }; - host.Add(view); - _app.Run(host); + vm.RequestClose -= handler; } return vm.Result; diff --git a/src/Typical/Services/ServiceExtensions.cs b/src/Typical/Services/ServiceExtensions.cs index 0058a5c..cbc5100 100644 --- a/src/Typical/Services/ServiceExtensions.cs +++ b/src/Typical/Services/ServiceExtensions.cs @@ -23,11 +23,11 @@ public static class ServiceExtensions /// public static Logger CreateAppLogger() => new LoggerConfiguration() - .MinimumLevel.Information() + .MinimumLevel.Verbose() .WriteTo.File( formatter: new MessageTemplateTextFormatter(OutputTemplate), Path.Combine(AppPaths.LogDirectory, "app-.log"), - restrictedToMinimumLevel: LogEventLevel.Debug, + restrictedToMinimumLevel: LogEventLevel.Verbose, shared: true, rollingInterval: RollingInterval.Day ) @@ -53,7 +53,7 @@ public static void AddTuiInfrastructure(this HostApplicationBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddTransient(); + //builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Typical/UI/Binding/BindingContext.cs b/src/Typical/UI/Binding/BindingContext.cs deleted file mode 100644 index 35ea098..0000000 --- a/src/Typical/UI/Binding/BindingContext.cs +++ /dev/null @@ -1,38 +0,0 @@ -// namespace Typical.UI.Binding; - -// /// -// /// Manages the lifecycle of multiple bindings, providing centralized cleanup. -// /// -// public class BindingContext : IDisposable -// { -// private readonly List _bindings = new(); -// private bool _disposed; - -// /// -// /// Adds a binding to be managed by this context. -// /// -// public void AddBinding(IDisposable binding) -// { -// if (_disposed) -// throw new ObjectDisposedException(nameof(BindingContext)); - -// _bindings.Add(binding); -// } - -// /// -// /// Disposes all managed bindings. -// /// -// public void Dispose() -// { -// if (!_disposed) -// { -// foreach (var binding in _bindings) -// { -// binding.Dispose(); -// } -// _bindings.Clear(); -// _disposed = true; -// GC.SuppressFinalize(this); -// } -// } -// } diff --git a/src/Typical/UI/Binding/BindingExtensions.cs b/src/Typical/UI/Binding/BindingExtensions.cs deleted file mode 100644 index 81f245a..0000000 --- a/src/Typical/UI/Binding/BindingExtensions.cs +++ /dev/null @@ -1,134 +0,0 @@ -// using System.ComponentModel; -// using System.Linq.Expressions; -// using System.Runtime.CompilerServices; -// using CommunityToolkit.Mvvm.ComponentModel; -// using CommunityToolkit.Mvvm.Input; -// using Terminal.Gui.ViewBase; -// using Terminal.Gui.Views; - -// namespace Typical.UI.Binding; - -// public static class BindingExtensions -// { -// /// -// /// Generic One-Way Binding: VM -> UI -// /// Works for strings, bools, ints, or custom objects. -// /// -// public static IDisposable Bind( -// this ObservableObject viewModel, -// Func propertyExpression, -// Action updateUi, -// [CallerArgumentExpression(nameof(propertyExpression))] string? expression = null -// ) -// { -// string propertyName = -// expression?.Split('.').Last() -// ?? throw new ArgumentException("Could not determine property name from expression."); - -// viewModel.PropertyChanged += Handler; -// updateUi(propertyExpression()); - -// return new DisposableAction(() => viewModel.PropertyChanged -= Handler); - -// void Handler(object? sender, PropertyChangedEventArgs e) -// { -// if (string.Equals(e.PropertyName, propertyName, StringComparison.Ordinal)) -// { -// updateUi(propertyExpression()); -// } -// } -// } - -// /// -// /// Two-Way String Binding: VM <-> TextField -// /// -// public static IDisposable BindText( -// this ObservableObject viewModel, -// View target, -// Func getter, -// Action? setter = null -// ) -// { -// var vmToUi = viewModel.Bind( -// getter, -// val => -// { -// if (target.Text != val) -// { -// target.Text = val; -// target.SetNeedsDraw(); -// } -// } -// ); - -// if (setter != null) -// { -// void OnTextChanged(object? s, EventArgs e) => setter(target.Text); -// target.TextChanged += OnTextChanged; - -// return new DisposableAction(() => -// { -// vmToUi.Dispose(); -// target.TextChanged -= OnTextChanged; -// }); -// } - -// return vmToUi; -// } - -// /// -// /// Two-Way Boolean Binding: VM <-> CheckBox -// /// -// public static IDisposable BindChecked( -// this ObservableObject viewModel, -// CheckBox checkBox, -// Func getter, -// Action setter -// ) -// { -// var vmToUi = viewModel.Bind( -// getter, -// val => -// { -// var newState = val ? CheckState.Checked : CheckState.UnChecked; -// if (checkBox.Value != newState) -// { -// checkBox.Value = newState; -// checkBox.SetNeedsDraw(); -// } -// } -// ); - -// void OnUiChanged(object? s, EventArgs e) => setter(checkBox.Value == CheckState.Checked); -// checkBox.Accepted += OnUiChanged; - -// return new DisposableAction(() => -// { -// vmToUi.Dispose(); -// checkBox.Accepted -= OnUiChanged; -// }); -// } - -// /// -// /// Command: Connects a ViewModel command to a Button. -// /// -// public static IDisposable BindCommand( -// this ObservableObject _, -// IRelayCommand command, -// Button button -// ) -// { -// void UpdateEnabled(object? s, EventArgs e) => button.Enabled = command.CanExecute(null); -// void OnAccept(object? s, EventArgs e) => command.Execute(null); - -// command.CanExecuteChanged += UpdateEnabled; -// button.Accepting += OnAccept; -// button.Enabled = command.CanExecute(null); - -// return new DisposableAction(() => -// { -// command.CanExecuteChanged -= UpdateEnabled; -// button.Accepting -= OnAccept; -// }); -// } -// } diff --git a/src/Typical/UI/Binding/DisposableAction.cs b/src/Typical/UI/Binding/DisposableAction.cs deleted file mode 100644 index ee43e29..0000000 --- a/src/Typical/UI/Binding/DisposableAction.cs +++ /dev/null @@ -1,25 +0,0 @@ -// namespace Typical.UI.Binding; - -// /// -// /// A simple disposable action that executes a delegate when disposed. -// /// Used for cleaning up event handlers and bindings. -// /// -// public class DisposableAction : IDisposable -// { -// private readonly Action _action; -// private bool _disposed; - -// public DisposableAction(Action action) -// { -// _action = action ?? throw new ArgumentNullException(nameof(action)); -// } - -// public void Dispose() -// { -// if (!_disposed) -// { -// _action(); -// _disposed = true; -// } -// } -// } diff --git a/src/Typical/UI/Views/BindableView.cs b/src/Typical/UI/Views/BindableView.cs deleted file mode 100644 index 6bbcf49..0000000 --- a/src/Typical/UI/Views/BindableView.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; -using CommunityToolkit.Mvvm.ComponentModel; -using Stanza.TerminalGui; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; -using Typical.Core.Interfaces; - -namespace Typical.UI.Views; - -public class TypicalDialog : Dialog - where TViewModel : ObservableObject -{ - private readonly TViewModel _viewModel; - private bool _disposed; - - public TypicalDialog(TViewModel viewModel) - { - _viewModel = viewModel; - } - - protected override void Dispose(bool disposing) - { - if (disposing && !_disposed) - { - // BindingContext.Dispose(); - _disposed = true; - } - base.Dispose(disposing); - } -} - -/// -/// Base class for Views that are bound to ViewModels. -/// Provides lifecycle management and binding context. -/// -public abstract class BindableView : View, INavigatableView - where TViewModel : ObservableObject -{ - /// - /// The ViewModel instance. - /// - protected readonly TViewModel ViewModel; - - /// - /// The binding context for managing bindings. - /// - protected readonly BindingContext BindingContext; - - private bool _disposed; - - /// - /// Initializes a new instance of the ViewModelView class. - /// - protected BindableView(TViewModel viewModel) - { - ViewModel = viewModel; - BindingContext = new BindingContext(); - - Initialized += (s, e) => SetupBindings(); - } - - /// - /// Template method for setting up bindings. - /// Override in derived classes to configure bindings. - /// - protected virtual void SetupBindings() { } - - /// - /// Called when a ViewModel property changes. - /// Override in derived classes for custom handling. - /// - protected virtual void OnViewModelPropertyChanged( - object? sender, - System.ComponentModel.PropertyChangedEventArgs e - ) - { - SetNeedsDraw(); - } - - /// - /// Called when the view is navigated to. - /// - public virtual void OnNavigatedTo() { } - - /// - /// Called when the view is navigated away from. - /// - public virtual void OnNavigatedFrom() { } - - /// - /// Disposes the view and cleans up bindings. - /// - protected override void Dispose(bool disposing) - { - if (disposing && !_disposed) - { - ViewModel.PropertyChanged -= OnViewModelPropertyChanged; - BindingContext.Dispose(); - _disposed = true; - } - base.Dispose(disposing); - } - - // protected void Bind( - // Func getter, - // Action updateUi, - // [CallerArgumentExpression(nameof(getter))] string expression = default! - // ) - // { - // BindingContext.AddBinding(ViewModel.Bind(getter, updateUi, expression)); - // } -} diff --git a/src/Typical/UI/Views/HomeView.cs b/src/Typical/UI/Views/HomeView.cs deleted file mode 100644 index 5a70ce9..0000000 --- a/src/Typical/UI/Views/HomeView.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; -using Typical.Core.ViewModels; - -namespace Typical.UI.Views; - -public class HomeView : BindableView -{ - public HomeView(HomeViewModel vm) - : base(vm) - { - Width = Dim.Fill(); - Height = Dim.Fill(); - - var btn = new Button { X = Pos.Center(), Text = "Go Settings" }; - - btn.Accepting += (s, e) => ViewModel.NavigateSettingsCommand.Execute(null); - Add(btn); - } - - protected override void SetupBindings() { } -} diff --git a/src/Typical/UI/Views/MainShell.cs b/src/Typical/UI/Views/MainShell.cs index 1e09731..f17b9f6 100644 --- a/src/Typical/UI/Views/MainShell.cs +++ b/src/Typical/UI/Views/MainShell.cs @@ -10,9 +10,9 @@ namespace Typical.UI.Views; -public class MainShell : Window +[StanzaView] +public partial class MainShell : Window { - private readonly MainViewModel _viewModel; private readonly IServiceProvider _serviceProvider; private readonly FrameView _headerFrame; private readonly View _contentFrame; @@ -20,16 +20,11 @@ public class MainShell : Window private readonly View _leftSpacer; private readonly View _rightSpacer; - // private readonly Label _statusLabel; - private readonly BindingContext _bindingContext; - public MainShell(MainViewModel viewModel, IServiceProvider sp) { - _viewModel = viewModel; _serviceProvider = sp; - _bindingContext = new BindingContext(); BorderStyle = LineStyle.None; - Title = _viewModel.AppTitle; + Title = viewModel.AppTitle; _leftSpacer = new View { @@ -83,12 +78,6 @@ public MainShell(MainViewModel viewModel, IServiceProvider sp) _footerFrame.Add(statsView); Add(_leftSpacer, _rightSpacer, _headerFrame, _contentFrame, _footerFrame); - _viewModel - .Bind(this, vm => vm.CurrentPage, _ => UpdateContent(_viewModel.CurrentPage)) - .AddTo(_bindingContext); - - _viewModel.NavigateToTestViewCommand.Execute(null); - this.Activating += (s, e) => { if (e.Context?.Binding is MouseBinding { MouseEvent: { } mouse }) @@ -101,15 +90,17 @@ public MainShell(MainViewModel viewModel, IServiceProvider sp) e.Handled = true; } }; + + ViewModel = viewModel; + + ViewModel.NavigateToTestViewCommand.Execute(null); } - protected override void Dispose(bool disposing) + partial void OnApplyBindings(BindingContext context) { - if (disposing) - { - _bindingContext.Dispose(); - } - base.Dispose(disposing); + if (ViewModel is not null) + this.Bind(ViewModel, vm => vm.CurrentPage, _ => UpdateContent(ViewModel.CurrentPage)) + .AddTo(_bindingContext); } private void UpdateContent(ObservableObject? viewModel) diff --git a/src/Typical/UI/Views/ResultsView.cs b/src/Typical/UI/Views/ResultsView.cs index 5dd9002..386525a 100644 --- a/src/Typical/UI/Views/ResultsView.cs +++ b/src/Typical/UI/Views/ResultsView.cs @@ -1,9 +1,12 @@ +using Stanza.TerminalGui; using Terminal.Gui.Input; +using Terminal.Gui.Views; using Typical.Core.ViewModels; namespace Typical.UI.Views; -public class ResultsDialog : TypicalDialog +[StanzaView] +public partial class ResultsDialog : Dialog { protected override bool OnAccepting(CommandEventArgs args) { @@ -15,9 +18,10 @@ protected override bool OnAccepting(CommandEventArgs args) } public ResultsDialog(ResultsViewModel viewModel) - : base(viewModel) { AddButton(new() { Text = "_Cancel" }); AddButton(new() { Text = "_Ok" }); + Add(new CheckBox()); + ViewModel = viewModel; } } diff --git a/src/Typical/UI/Views/SettingsView.cs b/src/Typical/UI/Views/SettingsView.cs index a6a846e..90e681a 100644 --- a/src/Typical/UI/Views/SettingsView.cs +++ b/src/Typical/UI/Views/SettingsView.cs @@ -1,19 +1,20 @@ using Stanza.TerminalGui; + using Terminal.Gui.ViewBase; using Terminal.Gui.Views; + using Typical.Core.ViewModels; namespace Typical.UI.Views; -public class SettingsView : View +[StanzaView] +public partial class SettingsView : View { + [BindCommand(nameof(SettingsViewModel.QuoteModeCommand))] private readonly Button _btnQuoteMode; - private readonly BindingContext _bindingContext; public SettingsView(SettingsViewModel viewModel) { - ViewModel = viewModel; - _bindingContext = new BindingContext(); Width = Dim.Fill(); Height = Dim.Fill(); @@ -21,22 +22,6 @@ public SettingsView(SettingsViewModel viewModel) Add(_btnQuoteMode); - ViewModel.QuoteModeCommand.BindCommand(_btnQuoteMode).AddTo(_bindingContext); - } - - public SettingsViewModel ViewModel { get; } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _bindingContext.Dispose(); // Cleans up command event subscriptions safely [1] - } - base.Dispose(disposing); + ViewModel = viewModel; } - - // protected override void SetupBindings() - // { - // BindingContext.AddBinding(ViewModel.BindCommand(ViewModel.QuoteModeCommand, _btnQuoteMode)); - // } } diff --git a/src/Typical/UI/Views/StatsView.cs b/src/Typical/UI/Views/StatsView.cs index cb8c393..53c8112 100644 --- a/src/Typical/UI/Views/StatsView.cs +++ b/src/Typical/UI/Views/StatsView.cs @@ -1,19 +1,21 @@ using Stanza.TerminalGui; + using Terminal.Gui.Drawing; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; + using Typical.Core.ViewModels; namespace Typical.UI.Views; -public class StatsView : View +[StanzaView] +public partial class StatsView : View { + [BindText(nameof(StatsViewModel.StatsLabel))] private readonly Label _statsLabel; - private readonly BindingContext _bindingContext; public StatsView(StatsViewModel viewModel) { - ViewModel = viewModel; Title = nameof(StatsView); BorderStyle = LineStyle.None; Height = 3; @@ -22,32 +24,12 @@ public StatsView(StatsViewModel viewModel) _statsLabel = new Label { X = Pos.Center(), Y = Pos.Center() }; Add(_statsLabel); - ViewModel - .Bind( - this, - vm => vm.Stats, - stats => - { - _statsLabel.Text = - $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WPM.Value)} | Acc: {stats.Accuracy.ToString()}"; - SetNeedsDraw(); - } - ) - .AddTo(_bindingContext); + ViewModel = viewModel; + } + + partial void OnApplyBindings(BindingContext context) + { + } - public StatsViewModel ViewModel { get; } - - // protected override void SetupBindings() - // { - // Bind( - // () => ViewModel.Stats, - // stats => - // { - // _statsLabel.Text = - // $"Elapsed: {stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(stats.WPM.Value)} | Acc: {stats.Accuracy.ToString()}"; - // SetNeedsDraw(); - // } - // ); - // } } diff --git a/src/Typical/UI/Views/TypingView.cs b/src/Typical/UI/Views/TypingView.cs index 85153d9..6c02352 100644 --- a/src/Typical/UI/Views/TypingView.cs +++ b/src/Typical/UI/Views/TypingView.cs @@ -1,4 +1,6 @@ using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Stanza.TerminalGui; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; @@ -12,8 +14,9 @@ public partial class TypingView : View { private readonly TypingArea _typingArea; private readonly Label _sourceLabel; + private readonly ILogger _logger; - public TypingView(TypingViewModel viewModel) + public TypingView(TypingViewModel viewModel, ILogger? logger = null) { CanFocus = true; X = Pos.Center(); @@ -21,6 +24,7 @@ public TypingView(TypingViewModel viewModel) Width = Dim.Fill(); Height = Dim.Fill(); ViewModel = viewModel; + _logger = logger ?? NullLogger.Instance; _bindingContext = new BindingContext(); _typingArea = new TypingArea(viewModel) { @@ -32,20 +36,16 @@ public TypingView(TypingViewModel viewModel) _sourceLabel = new Label(); Add(_typingArea); - ViewModel - .Bind( - this, + this.Bind( + ViewModel, vm => vm.Target, target => { _typingArea.Refresh(); _sourceLabel.Text = target?.Source ?? string.Empty; - SetNeedsDraw(); } ) - .AddTo(BindingContext); - - ViewModel.RefreshRequested += OnViewModelRefreshRequested; + .AddTo(_bindingContext); Initialized += (s, e) => { @@ -58,11 +58,6 @@ public TypingView(TypingViewModel viewModel) }; } - private void OnViewModelRefreshRequested(object? sender, EventArgs e) - { - App?.Invoke(() => ViewModel?.RefreshState()); - } - protected override void OnSubViewsLaidOut(LayoutEventArgs args) { base.OnSubViewsLaidOut(args); @@ -91,7 +86,7 @@ protected override bool OnKeyDown(Key key) } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Input Error: {ex.Message}"); + _logger.LogError(ex, $"Input Error: {ex.Message}"); } return true; @@ -109,7 +104,7 @@ private async Task InitializeViewAsync() } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Init Error: {ex.Message}"); + _logger.LogError(ex, $"Init Error: {ex.Message}"); } } } From 9986d1d7121045b1373a6c4876fd78f77267c6e2 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:59:12 +0100 Subject: [PATCH 49/58] wip: Results --- .../ViewModels/ResultsViewModel.cs | 11 +++++++- src/Typical/Services/NavigationService.cs | 12 ++++++++- src/Typical/UI/Views/ResultsView.cs | 27 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/Typical.Core/ViewModels/ResultsViewModel.cs b/src/Typical.Core/ViewModels/ResultsViewModel.cs index 4f1d6eb..0b09071 100644 --- a/src/Typical.Core/ViewModels/ResultsViewModel.cs +++ b/src/Typical.Core/ViewModels/ResultsViewModel.cs @@ -1,4 +1,7 @@ +using System.Collections.ObjectModel; + using CommunityToolkit.Mvvm.ComponentModel; + using Typical.Core.Interfaces; using Typical.Core.Statistics; @@ -8,10 +11,16 @@ public class ResultsViewModel : ObservableObject, IModalViewModel { public bool Result => true; + public ObservableCollection Snapshots { get; } = new(); + public event EventHandler? RequestClose; public void Initialize(TestResult result) { - // TODO: finish implementing display of results + Snapshots.Clear(); + foreach (var snap in result.Snapshots) + { + Snapshots.Add(snap); + } } } diff --git a/src/Typical/Services/NavigationService.cs b/src/Typical/Services/NavigationService.cs index b4ab275..8430037 100644 --- a/src/Typical/Services/NavigationService.cs +++ b/src/Typical/Services/NavigationService.cs @@ -1,9 +1,14 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; + +using Stanza.TerminalGui; + using Terminal.Gui.App; using Terminal.Gui.Views; + using Typical.Core.Events; using Typical.Core.Interfaces; using Typical.Navigation; @@ -69,9 +74,12 @@ public void NavigateTo(Action configure) where TViewModel : class, IModalViewModel { var vm = _services.GetRequiredService(); - configure?.Invoke(vm); var view = ViewLocator.GetView(_services, vm); + if (typeof(view).) + { + + } EventHandler? handler = null; handler = (s, e) => _app.RequestStop(); vm.RequestClose += handler; @@ -110,6 +118,8 @@ public void NavigateTo(Action configure) finally { vm.RequestClose -= handler; + + view.Dispose(); } return vm.Result; diff --git a/src/Typical/UI/Views/ResultsView.cs b/src/Typical/UI/Views/ResultsView.cs index 386525a..fd7ef07 100644 --- a/src/Typical/UI/Views/ResultsView.cs +++ b/src/Typical/UI/Views/ResultsView.cs @@ -1,6 +1,10 @@ +using System.Drawing; + using Stanza.TerminalGui; + using Terminal.Gui.Input; using Terminal.Gui.Views; + using Typical.Core.ViewModels; namespace Typical.UI.Views; @@ -8,6 +12,8 @@ namespace Typical.UI.Views; [StanzaView] public partial class ResultsDialog : Dialog { + private readonly GraphView _graph; + protected override bool OnAccepting(CommandEventArgs args) { if (base.OnAccepting(args)) @@ -23,5 +29,26 @@ public ResultsDialog(ResultsViewModel viewModel) AddButton(new() { Text = "_Ok" }); Add(new CheckBox()); ViewModel = viewModel; + + _graph = new GraphView(); + } + partial void OnApplyBindings(BindingContext context) + { + if (ViewModel == null) return; + + Action refreshGraph = () => + { + _graph.Reset(); + + var wpmPoints = ViewModel.Snapshots + .Select(s => new PointF((float)s.ElapsedTime.TotalSeconds, (float)s.WPM.Value)) + .ToList(); + + _graph.Series.Add(new ScatterSeries + { + Points = wpmPoints, + }); + _graph.SetNeedsDraw(); + }; } } From c21cc4b4c71413ae8462880d5b6283fc98376643 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:01:28 +0100 Subject: [PATCH 50/58] wip --- src/Directory.Packages.props | 2 +- src/Typical.Core/Data/IStatsRepository.cs | 1 + src/Typical.Core/Events/TestResetMessage.cs | 1 + .../Interfaces/IModalViewModel.cs | 4 +- src/Typical.Core/ViewModels/MainViewModel.cs | 20 ++++- .../ViewModels/ResultsViewModel.cs | 45 ++++++++++ .../ViewModels/SettingsViewModel.cs | 8 ++ .../Sqlite/SimpleStatsRepository.cs | 5 ++ .../Sqlite/StatsRepository.cs | 72 +++++++++++++++- src/Typical/Services/NavigationService.cs | 53 ++++-------- src/Typical/UI/Views/ResultsView.cs | 86 +++++++++++++++---- src/Typical/UI/Views/SettingsView.cs | 3 + 12 files changed, 240 insertions(+), 60 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 3606863..cac7110 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -47,7 +47,7 @@ - + diff --git a/src/Typical.Core/Data/IStatsRepository.cs b/src/Typical.Core/Data/IStatsRepository.cs index efd1b4b..7b0abc4 100644 --- a/src/Typical.Core/Data/IStatsRepository.cs +++ b/src/Typical.Core/Data/IStatsRepository.cs @@ -4,5 +4,6 @@ namespace Typical.Core.Data; public interface IStatsRepository { + TestResult GetTestResultAsync(); Task SaveTestResultAsync(TestResult result); } diff --git a/src/Typical.Core/Events/TestResetMessage.cs b/src/Typical.Core/Events/TestResetMessage.cs index a80f088..6404cc9 100644 --- a/src/Typical.Core/Events/TestResetMessage.cs +++ b/src/Typical.Core/Events/TestResetMessage.cs @@ -1,3 +1,4 @@ namespace Typical.Core.Events; public record TestResetMessage(ModeSettings Settings); +public record ShowResultDialogMessage(bool Random); diff --git a/src/Typical.Core/Interfaces/IModalViewModel.cs b/src/Typical.Core/Interfaces/IModalViewModel.cs index 71002bd..b97e1e7 100644 --- a/src/Typical.Core/Interfaces/IModalViewModel.cs +++ b/src/Typical.Core/Interfaces/IModalViewModel.cs @@ -1,6 +1,8 @@ +using System.ComponentModel; + namespace Typical.Core.Interfaces; -public interface IModalViewModel +public interface IModalViewModel : INotifyPropertyChanged { // The result the modal will return (e.g., a bool, a string, or a complex object) TResult? Result { get; } diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index b6b8622..2b28c42 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -1,9 +1,13 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; + using Microsoft.Extensions.Logging; + +using Typical.Core.Data; using Typical.Core.Events; using Typical.Core.Interfaces; +using Typical.Core.Statistics; using Typical.Core.Text; namespace Typical.Core.ViewModels; @@ -11,6 +15,7 @@ namespace Typical.Core.ViewModels; public sealed partial class MainViewModel : ObservableObject { private readonly INavigationService _navigationService; + private readonly IStatsRepository _statsRepository; private readonly IDialogService _dialogService; private readonly ILogger _logger; private readonly IMessenger _messenger; @@ -29,7 +34,8 @@ public MainViewModel( IDialogService dialogService, ILogger logger, IMessenger messenger - ) +, + IStatsRepository statsRepository) { _navigationService = navigationService; _navigationService.PropertyChanged += (s, e) => @@ -45,6 +51,8 @@ IMessenger messenger _messenger.Register(this, (r, m) => r.Receive(m)); _messenger.Register(this, (r, m) => r.Receive(m)); + _messenger.Register(this, (r, m) => r.Receive(m)); + _statsRepository = statsRepository; } [RelayCommand] @@ -74,4 +82,14 @@ public async void Receive(TestResetMessage message) vm.InitializeAsync().Wait(); }); } + + public async void Receive(ShowResultDialogMessage message) + { + TestResult result = _statsRepository.GetTestResultAsync(); + _navigationService.ShowModal((vm) => + { + vm.Initialize(result); + }); + } + } diff --git a/src/Typical.Core/ViewModels/ResultsViewModel.cs b/src/Typical.Core/ViewModels/ResultsViewModel.cs index 0b09071..452705a 100644 --- a/src/Typical.Core/ViewModels/ResultsViewModel.cs +++ b/src/Typical.Core/ViewModels/ResultsViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Drawing; using CommunityToolkit.Mvvm.ComponentModel; @@ -12,11 +13,17 @@ public class ResultsViewModel : ObservableObject, IModalViewModel public bool Result => true; public ObservableCollection Snapshots { get; } = new(); + public IEnumerable WpmSeriesData => + Snapshots.Select(s => new PointF((float)s.ElapsedTime.TotalSeconds, (float)s.WPM.Value)); + public GraphData GraphContext => new GraphData(Snapshots); + + public string Source { get; private set; } = string.Empty; public event EventHandler? RequestClose; public void Initialize(TestResult result) { + Source = result.Target.Source; Snapshots.Clear(); foreach (var snap in result.Snapshots) { @@ -24,3 +31,41 @@ public void Initialize(TestResult result) } } } +public class GraphData +{ + private readonly List _snapshots = new List(); + + public GraphData(IEnumerable snapshots) + { + _snapshots = snapshots.ToList(); + } + + public List Points => _snapshots + .Select(s => new PointF((float)s.ElapsedTime.TotalSeconds, (float)s.WPM)).ToList(); + + // ANALYZE: Provides the bounds for scaling + public float MinWpm => _snapshots.Any() ? _snapshots.Min(s => (float)s.WPM) : 0; + public float MaxWpm => _snapshots.Any() ? _snapshots.Max(s => (float)s.WPM) : 100; + public float TotalSeconds => _snapshots.Any() ? (float)_snapshots.Max(s => s.ElapsedTime.TotalSeconds) : 0; + + // CALCULATE: Logic for the "Perfect Frame" + public (PointF CellSize, PointF ScrollOffset, float Increment) GetScale(Size viewport, uint marginLeft, uint marginBottom) + { + float availableWidth = viewport.Width - marginLeft; + float availableHeight = viewport.Height - marginBottom; + + float buffer = 10f; + float yMin = Math.Max(0, MinWpm - buffer); + float yRange = (MaxWpm + buffer) - yMin; + + // X and Y scaling + float cx = availableWidth > 0 ? TotalSeconds / availableWidth : 1f; + float cy = availableHeight > 0 ? yRange / availableHeight : 1f; + + // Axis Ticks: One label every 4 rows is a good visual density + float increment = Math.Max(10f, (float)Math.Ceiling(cy * 4 / 5.0) * 5); + + return (new PointF(cx, cy), new PointF(0, yMin), increment); + } + +} diff --git a/src/Typical.Core/ViewModels/SettingsViewModel.cs b/src/Typical.Core/ViewModels/SettingsViewModel.cs index 76f55d4..4ddeabe 100644 --- a/src/Typical.Core/ViewModels/SettingsViewModel.cs +++ b/src/Typical.Core/ViewModels/SettingsViewModel.cs @@ -37,6 +37,14 @@ private void QuoteMode() _messenger.Send(message); } + [RelayCommand] + private void ShowRandomResult() + { + var message = new ShowResultDialogMessage(Random: true); + _messenger.Send(message); + } + //[RelayCommand] //private void Cancel() => _navService.NavigateTo(); } + diff --git a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs index 5e1f8b3..c40369d 100644 --- a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs @@ -6,6 +6,11 @@ namespace Typical.DataAccess.Sqlite; public class SimpleStatsRepository : IStatsRepository { + public TestResult GetTestResultAsync() + { + throw new NotImplementedException(); + } + public Task SaveTestResultAsync(TestResult result) { Debug.WriteLine("SimpleStatsRepository"); diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index edf5179..f1927ff 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -1,10 +1,14 @@ using System.Reflection; using System.Text; + using Dapper; + using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; + using Typical.Core.Data; using Typical.Core.Statistics; +using Typical.Core.Text; namespace Typical.DataAccess.Sqlite; @@ -21,7 +25,7 @@ public async Task SaveTestResultAsync(TestResult result) await InsertTelemetryAsync(connection, transaction, testId, result); - await InsertSnapshotsAsync(connection, transaction, testId, result); + await InsertSnapshotsAsync(connection, transaction, testId, result.Snapshots); await transaction.CommitAsync(); } @@ -32,6 +36,65 @@ public async Task SaveTestResultAsync(TestResult result) } } + public async Task GetTestResultAsync(int? id = null) + { + await using var connection = await GetConnectionAsync(); + + if (id is null) + { + int maxId = await connection.ExecuteScalarAsync(@"SELECT MAX(id) FROM Tests;"); + id = Random.Shared.Next(1, maxId + 1); + } + + // We fetch the Test, the Quote (if it exists), and all Snapshots + const string sql = @" + SELECT * FROM Tests WHERE Id = @testId; + + SELECT + q.Id as SourceId, + q.Text as Text, + q.Author as Source, + q.CharCount, + q.WordCount + FROM Quotes q + INNER JOIN Tests t ON t.QuoteId = q.Id + WHERE t.Id = @testId; + + SELECT * FROM TestSnapshots WHERE TestId = @testId ORDER BY OffsetMs ASC;"; + // Read using the Row DTOs + using var multi =await connection.QueryMultipleAsync(sql); + + var testRow = await multi.ReadSingle(); + + var quoteRow = await multi.ReadSingle(); + var snapshots = (await multi.ReadAsync()).ToList(); + + // MAP TO DOMAIN + // 1. Convert QuoteRow -> TextSample + TextSample sample = quoteRow != null + ? new TextSample + { + SourceId = quoteRow.Id, + Text = quoteRow.Text, + Source = quoteRow.Author ?? "Unknown", + WordCount = quoteRow.WordCount, + CharCount = quoteRow.CharCount + } + : new TextSample + { + Text = testRow.CustomText ?? "", + Source = "Custom Text" + }; + + // 2. Construct the final Record + return new TestResult( + FinalWpm: (float)testRow.Wpm, + PlayedAt: DateTimeOffset.FromUnixTimeSeconds(testRow.CreatedAt).DateTime, + Target: sample, + Snapshots: snapshots + ); + } + private async Task InsertTestHeaderAsync( SqliteConnection conn, SqliteTransaction trans, @@ -105,10 +168,10 @@ private async Task InsertSnapshotsAsync( SqliteConnection conn, SqliteTransaction trans, long testId, - TestResult result + IEnumerable snapshots ) { - if (result.Snapshots.Count == 0) + if (snapshots.Count() == 0) return; const string sql = """ @@ -124,7 +187,7 @@ INSERT INTO TestSnapshots (TestId, OffsetMs, Wpm, Accuracy) pTestId.Value = testId; - foreach (var snap in result.Snapshots) + foreach (var snap in snapshots) { pTestId.Value = testId; pOffset.Value = (long)snap.ElapsedTime.TotalMilliseconds; @@ -142,4 +205,5 @@ private async Task GetConnectionAsync() await connection.OpenAsync(); return connection; } + } diff --git a/src/Typical/Services/NavigationService.cs b/src/Typical/Services/NavigationService.cs index 8430037..d421dfe 100644 --- a/src/Typical/Services/NavigationService.cs +++ b/src/Typical/Services/NavigationService.cs @@ -74,52 +74,35 @@ public void NavigateTo(Action configure) where TViewModel : class, IModalViewModel { var vm = _services.GetRequiredService(); + configure?.Invoke(vm); + var view = ViewLocator.GetView(_services, vm); - if (typeof(view).) + if (view is IStanzaView stanzaView) { + stanzaView.ViewModel = vm; + } + // Differentiate only if you want to support non-Dialog Windows (standard top-level views) + // but typically for Modals, Dialog is the correct constraint. + if (view is not Dialog dialog) + { + throw new InvalidOperationException($"View for {typeof(TViewModel).Name} must be a Dialog."); } - EventHandler? handler = null; - handler = (s, e) => _app.RequestStop(); - vm.RequestClose += handler; + + void StopRequest(object? s, EventArgs e) => _app.RequestStop(); + vm.RequestClose += StopRequest; try { - if (view is Dialog dialog) - { - _logger.LogInformation( - "Showing modal dialog directly for {ViewModelType}", - typeof(TViewModel).Name - ); - _app.Run(dialog); - } - else if (view is IRunnable runnable) - { - _logger.LogInformation( - "Showing runnable modal view for {ViewModelType}: {ViewType}", - typeof(TViewModel).Name, - view.GetType().Name - ); - _app.Run(runnable); - } - else - { - _logger.LogInformation( - "Wrapping non-runnable modal view for {ViewModelType}: {ViewType}", - typeof(TViewModel).Name, - view.GetType().Name - ); - var host = new Dialog { Title = "Modal Host" }; - host.Add(view); - _app.Run(host); - } + // Terminal.Gui v2 logic: Application.Run works on any View, + // but Dialogs provide the modal overlay/shadowing behavior. + _app.Run(dialog); } finally { - vm.RequestClose -= handler; - - view.Dispose(); + vm.RequestClose -= StopRequest; + view.Dispose(); // Kills Stanza bindings } return vm.Result; diff --git a/src/Typical/UI/Views/ResultsView.cs b/src/Typical/UI/Views/ResultsView.cs index fd7ef07..104e2dd 100644 --- a/src/Typical/UI/Views/ResultsView.cs +++ b/src/Typical/UI/Views/ResultsView.cs @@ -2,9 +2,12 @@ using Stanza.TerminalGui; +using Terminal.Gui.Drawing; using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; using Terminal.Gui.Views; +using Typical.Core.Statistics; using Typical.Core.ViewModels; namespace Typical.UI.Views; @@ -13,6 +16,7 @@ namespace Typical.UI.Views; public partial class ResultsDialog : Dialog { private readonly GraphView _graph; + private readonly Label _sourceLabel; protected override bool OnAccepting(CommandEventArgs args) { @@ -25,30 +29,76 @@ protected override bool OnAccepting(CommandEventArgs args) public ResultsDialog(ResultsViewModel viewModel) { - AddButton(new() { Text = "_Cancel" }); - AddButton(new() { Text = "_Ok" }); - Add(new CheckBox()); - ViewModel = viewModel; + Height = Dim.Percent(90); + Width = Dim.Percent(50); + _graph = new GraphView + { + Width = Dim.Fill(), + Height = Dim.Fill(), // leave room for buttons + MarginLeft = 3, // Space for Y-axis labels (e.g. "120.00") + MarginBottom = 2, // Space for X-axis labels + }; + + _graph.CellSize = new PointF(2.0f, 5.0f); + + //_graph.MarginLeft = 3; + //_graph.MarginBottom = 2; - _graph = new GraphView(); + // X Axis (Time) + _graph.AxisX.Text = "Time (s)"; + _graph.AxisX.Increment = 5f; // Tick every 5 seconds + _graph.AxisX.ShowLabelsEvery = 2; // Label every 10 seconds (5 * 2) + _graph.AxisX.LabelGetter = (v) => $"{v.Value:0}s"; + + // Y Axis (WPM) + _graph.AxisY.Text = "WPM"; + _graph.AxisY.Increment = 5f; // Tick every 10 WPM + _graph.AxisY.ShowLabelsEvery = 2; // Label every tick (10, 20, 30...) + Add(_graph); + + _sourceLabel = new Label(); + _sourceLabel.Text = "Unknown"; + _sourceLabel.Y = Pos.Bottom(_graph); + Add(_sourceLabel); + AddButton(new() { Text = "_Ok" , X = Pos.Center()}); + ViewModel = viewModel; } partial void OnApplyBindings(BindingContext context) { - if (ViewModel == null) return; + if (ViewModel is null) return; - Action refreshGraph = () => - { - _graph.Reset(); + var ctx = ViewModel.GraphContext; - var wpmPoints = ViewModel.Snapshots - .Select(s => new PointF((float)s.ElapsedTime.TotalSeconds, (float)s.WPM.Value)) - .ToList(); + var data = ctx.Points; - _graph.Series.Add(new ScatterSeries - { - Points = wpmPoints, - }); - _graph.SetNeedsDraw(); - }; + if (!data.Any()) return; + + // 1. Get the "Perfect Scale" from our data object + var (cellSize, scrollOffset, yIncrement) = ctx.GetScale( + _graph.Viewport.Size, + _graph.MarginLeft, + _graph.MarginBottom + ); + + // 2. Apply to UI + _graph.CellSize = cellSize; + _graph.ScrollOffset = scrollOffset; + _graph.AxisY.Minimum = scrollOffset.Y; + _graph.AxisY.Increment = yIncrement; + + _graph.Series.Clear(); + _graph.Series.Add(new ScatterSeries { Points = data }); + + _graph.Annotations.Clear(); + var path = new PathAnnotation { BeforeSeries = true }; + path.Points.AddRange(data); + _graph.Annotations.Add(path); + + _graph.SetNeedsDraw(); + + + _sourceLabel.Text = ViewModel.Source; + SetNeedsDraw(); } } + diff --git a/src/Typical/UI/Views/SettingsView.cs b/src/Typical/UI/Views/SettingsView.cs index 90e681a..948a7f7 100644 --- a/src/Typical/UI/Views/SettingsView.cs +++ b/src/Typical/UI/Views/SettingsView.cs @@ -13,6 +13,9 @@ public partial class SettingsView : View [BindCommand(nameof(SettingsViewModel.QuoteModeCommand))] private readonly Button _btnQuoteMode; + [BindCommand(nameof(SettingsViewModel.ShowRandomResultCommand))] + private readonly Button _btnRandomResult; + public SettingsView(SettingsViewModel viewModel) { Width = Dim.Fill(); From 0cece617aaa87173e54369aa6dfd10f8e52b1c2f Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Wed, 17 Jun 2026 17:43:18 +0100 Subject: [PATCH 51/58] wip --- .config/dotnet-tools.json | 6 +-- src/Typical.Core/Data/IStatsRepository.cs | 2 +- src/Typical.Core/ViewModels/MainViewModel.cs | 2 +- .../Sqlite/SimpleStatsRepository.cs | 27 ++++++----- .../Sqlite/StatsRepository.cs | 47 ++++++++++++++----- src/Typical/UI/Views/SettingsView.cs | 4 +- 6 files changed, 55 insertions(+), 33 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 1b7413a..b7aad43 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-reportgenerator-globaltool": { - "version": "5.5.9", + "version": "5.5.10", "commands": [ "reportgenerator" ], @@ -17,14 +17,14 @@ "rollForward": false }, "loom.build": { - "version": "0.6.0", + "version": "0.6.1", "commands": [ "loom" ], "rollForward": false }, "vpk": { - "version": "0.0.1589-ga2c5a97", + "version": "1.2.0", "commands": [ "vpk" ], diff --git a/src/Typical.Core/Data/IStatsRepository.cs b/src/Typical.Core/Data/IStatsRepository.cs index 7b0abc4..708dfac 100644 --- a/src/Typical.Core/Data/IStatsRepository.cs +++ b/src/Typical.Core/Data/IStatsRepository.cs @@ -4,6 +4,6 @@ namespace Typical.Core.Data; public interface IStatsRepository { - TestResult GetTestResultAsync(); + Task GetTestResultAsync(int? id = null); Task SaveTestResultAsync(TestResult result); } diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index 2b28c42..a777761 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -85,7 +85,7 @@ public async void Receive(TestResetMessage message) public async void Receive(ShowResultDialogMessage message) { - TestResult result = _statsRepository.GetTestResultAsync(); + TestResult result = await _statsRepository.GetTestResultAsync(); _navigationService.ShowModal((vm) => { vm.Initialize(result); diff --git a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs index c40369d..e9481e2 100644 --- a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs @@ -1,22 +1,23 @@ using System.Diagnostics; + using Typical.Core.Data; using Typical.Core.Statistics; namespace Typical.DataAccess.Sqlite; -public class SimpleStatsRepository : IStatsRepository -{ - public TestResult GetTestResultAsync() - { - throw new NotImplementedException(); - } +// public class SimpleStatsRepository : IStatsRepository +// { +// public TestResult GetTestResultAsync() +// { +// throw new NotImplementedException(); +// } - public Task SaveTestResultAsync(TestResult result) - { - Debug.WriteLine("SimpleStatsRepository"); +// public Task SaveTestResultAsync(TestResult result) +// { +// Debug.WriteLine("SimpleStatsRepository"); - Debug.WriteLine(result.ToString()); +// Debug.WriteLine(result.ToString()); - return Task.CompletedTask; - } -} +// return Task.CompletedTask; +// } +// } diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index f1927ff..f6441dd 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.Design; using System.Reflection; using System.Text; @@ -60,13 +61,16 @@ FROM Quotes q INNER JOIN Tests t ON t.QuoteId = q.Id WHERE t.Id = @testId; - SELECT * FROM TestSnapshots WHERE TestId = @testId ORDER BY OffsetMs ASC;"; + SELECT * FROM TestSnapshots WHERE TestId = @testId ORDER BY OffsetMs ASC; + SELECT * FROM KeystrokeTelemetry WHERE TestId = @testId ORDER BY OFfsetMs ASC; + "; + // Read using the Row DTOs - using var multi =await connection.QueryMultipleAsync(sql); + using var multi = await connection.QueryMultipleAsync(sql, new { testId = id }); - var testRow = await multi.ReadSingle(); + var testRow = await multi.ReadSingleAsync(); - var quoteRow = await multi.ReadSingle(); + var quoteRow = await multi.ReadSingleAsync(); var snapshots = (await multi.ReadAsync()).ToList(); // MAP TO DOMAIN @@ -78,20 +82,19 @@ FROM Quotes q Text = quoteRow.Text, Source = quoteRow.Author ?? "Unknown", WordCount = quoteRow.WordCount, - CharCount = quoteRow.CharCount + CharCount = quoteRow.CharCount, } - : new TextSample - { - Text = testRow.CustomText ?? "", - Source = "Custom Text" - }; + : TextSample.Empty; // 2. Construct the final Record return new TestResult( - FinalWpm: (float)testRow.Wpm, PlayedAt: DateTimeOffset.FromUnixTimeSeconds(testRow.CreatedAt).DateTime, - Target: sample, - Snapshots: snapshots + FinalWpm: Wpm.From(testRow.Wpm), + FinalAccuracy: Accuracy.From(testRow.Accuracy), + Duration: TimeSpan.FromMilliseconds(testRow.DurationMs), +Target: TextSample.Empty, + Telemetry: [], + Snapshots: snapshots, RawWpm: Wpm.From(testRow.Wpm) ); } @@ -206,4 +209,22 @@ private async Task GetConnectionAsync() return connection; } + +} + +internal class QuoteRow +{ + public int? Id { get; internal set; } + public string Text { get; internal set; } + public string? Author { get; internal set; } + public int WordCount { get; internal set; } + public int CharCount { get; internal set; } +} + +internal class TestRow +{ + public float Wpm { get; internal set; } + public long CreatedAt { get; internal set; } + public float Accuracy { get; internal set; } + public double DurationMs { get; internal set; } } diff --git a/src/Typical/UI/Views/SettingsView.cs b/src/Typical/UI/Views/SettingsView.cs index 948a7f7..29b0a49 100644 --- a/src/Typical/UI/Views/SettingsView.cs +++ b/src/Typical/UI/Views/SettingsView.cs @@ -22,8 +22,8 @@ public SettingsView(SettingsViewModel viewModel) Height = Dim.Fill(); _btnQuoteMode = new Button { X = Pos.Center(), Text = "Quote" }; - - Add(_btnQuoteMode); + _btnRandomResult = new Button { X = Pos.Right(_btnQuoteMode), Text = "Random Result" }; + Add(_btnQuoteMode, _btnRandomResult); ViewModel = viewModel; } From cd4a1902bf599ccb792b7538deb625b94e3af67f Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:35:43 +0100 Subject: [PATCH 52/58] fix test result retrieval --- .../Sqlite/StatsRepository.cs | 105 +++++++++--------- 1 file changed, 54 insertions(+), 51 deletions(-) diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index f6441dd..8e8719f 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -47,54 +47,63 @@ public async Task GetTestResultAsync(int? id = null) id = Random.Shared.Next(1, maxId + 1); } - // We fetch the Test, the Quote (if it exists), and all Snapshots - const string sql = @" - SELECT * FROM Tests WHERE Id = @testId; - - SELECT - q.Id as SourceId, - q.Text as Text, - q.Author as Source, - q.CharCount, - q.WordCount - FROM Quotes q - INNER JOIN Tests t ON t.QuoteId = q.Id + const string testSql = + @" + SELECT + t.Wpm, + t.CreatedAt, + t.Accuracy, + t.DurationMs, + q.Id as QuoteId, + q.Text as QuoteText, + q.Author as QuoteAuthor, + q.CharCount, + q.WordCount + FROM Tests t + LEFT JOIN Quotes q ON t.QuoteId = q.Id WHERE t.Id = @testId; - - SELECT * FROM TestSnapshots WHERE TestId = @testId ORDER BY OffsetMs ASC; - SELECT * FROM KeystrokeTelemetry WHERE TestId = @testId ORDER BY OFfsetMs ASC; "; - // Read using the Row DTOs - using var multi = await connection.QueryMultipleAsync(sql, new { testId = id }); + var testRow = await connection.QueryFirstOrDefaultAsync( + testSql, + new { testId = id } + ); - var testRow = await multi.ReadSingleAsync(); + const string snapshotsSql = + @" + SELECT * FROM TestSnapshots WHERE TestId = @testId ORDER BY OffsetMs ASC; + "; - var quoteRow = await multi.ReadSingleAsync(); - var snapshots = (await multi.ReadAsync()).ToList(); + var snapshots = ( + await connection.QueryAsync(snapshotsSql, new { testId = id }) + ).ToList(); - // MAP TO DOMAIN - // 1. Convert QuoteRow -> TextSample - TextSample sample = quoteRow != null - ? new TextSample + TextSample sample; + if (testRow?.QuoteId is not null) + { + sample = new TextSample { - SourceId = quoteRow.Id, - Text = quoteRow.Text, - Source = quoteRow.Author ?? "Unknown", - WordCount = quoteRow.WordCount, - CharCount = quoteRow.CharCount, - } - : TextSample.Empty; - - // 2. Construct the final Record + SourceId = testRow.QuoteId, + Text = testRow.QuoteText!, + Source = testRow.QuoteAuthor ?? "Unknown", + WordCount = testRow.WordCount, + CharCount = testRow.CharCount, + }; + } + else + { + sample = TextSample.Empty; + } + return new TestResult( - PlayedAt: DateTimeOffset.FromUnixTimeSeconds(testRow.CreatedAt).DateTime, + PlayedAt: DateTimeOffset.FromUnixTimeSeconds(testRow!.CreatedAt).DateTime, FinalWpm: Wpm.From(testRow.Wpm), FinalAccuracy: Accuracy.From(testRow.Accuracy), Duration: TimeSpan.FromMilliseconds(testRow.DurationMs), -Target: TextSample.Empty, + Target: sample, Telemetry: [], - Snapshots: snapshots, RawWpm: Wpm.From(testRow.Wpm) + Snapshots: snapshots, + RawWpm: Wpm.From(testRow.Wpm) ); } @@ -208,23 +217,17 @@ private async Task GetConnectionAsync() await connection.OpenAsync(); return connection; } - - -} - -internal class QuoteRow -{ - public int? Id { get; internal set; } - public string Text { get; internal set; } - public string? Author { get; internal set; } - public int WordCount { get; internal set; } - public int CharCount { get; internal set; } } internal class TestRow { - public float Wpm { get; internal set; } - public long CreatedAt { get; internal set; } - public float Accuracy { get; internal set; } - public double DurationMs { get; internal set; } + public float Wpm { get; set; } + public long CreatedAt { get; set; } + public float Accuracy { get; set; } + public double DurationMs { get; set; } + public int? QuoteId { get; set; } + public string? QuoteText { get; set; } + public string? QuoteAuthor { get; set; } + public int CharCount { get; set; } + public int WordCount { get; set; } } From b96d9cf21c72f34b655418c2a9c14d29658752dd Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:36:10 +0100 Subject: [PATCH 53/58] chore: formatting --- src/Typical.Core/Events/TestResetMessage.cs | 1 + src/Typical.Core/ViewModels/MainViewModel.cs | 19 +++++++++---------- .../ViewModels/ResultsViewModel.cs | 19 ++++++++++++------- .../ViewModels/SettingsViewModel.cs | 3 ++- src/Typical.Core/ViewModels/StatsViewModel.cs | 3 ++- .../Sqlite/SimpleStatsRepository.cs | 1 - src/Typical/Services/NavigationService.cs | 10 ++++------ src/Typical/UI/Views/ResultsView.cs | 14 ++++++-------- src/Typical/UI/Views/SettingsView.cs | 2 -- src/Typical/UI/Views/StatsView.cs | 8 +------- 10 files changed, 37 insertions(+), 43 deletions(-) diff --git a/src/Typical.Core/Events/TestResetMessage.cs b/src/Typical.Core/Events/TestResetMessage.cs index 6404cc9..76f64d6 100644 --- a/src/Typical.Core/Events/TestResetMessage.cs +++ b/src/Typical.Core/Events/TestResetMessage.cs @@ -1,4 +1,5 @@ namespace Typical.Core.Events; public record TestResetMessage(ModeSettings Settings); + public record ShowResultDialogMessage(bool Random); diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index a777761..4748960 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -1,9 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; - using Microsoft.Extensions.Logging; - using Typical.Core.Data; using Typical.Core.Events; using Typical.Core.Interfaces; @@ -33,9 +31,9 @@ public MainViewModel( INavigationService navigationService, IDialogService dialogService, ILogger logger, - IMessenger messenger -, - IStatsRepository statsRepository) + IMessenger messenger, + IStatsRepository statsRepository + ) { _navigationService = navigationService; _navigationService.PropertyChanged += (s, e) => @@ -86,10 +84,11 @@ public async void Receive(TestResetMessage message) public async void Receive(ShowResultDialogMessage message) { TestResult result = await _statsRepository.GetTestResultAsync(); - _navigationService.ShowModal((vm) => - { - vm.Initialize(result); - }); + _navigationService.ShowModal( + (vm) => + { + vm.Initialize(result); + } + ); } - } diff --git a/src/Typical.Core/ViewModels/ResultsViewModel.cs b/src/Typical.Core/ViewModels/ResultsViewModel.cs index 452705a..7b97e7c 100644 --- a/src/Typical.Core/ViewModels/ResultsViewModel.cs +++ b/src/Typical.Core/ViewModels/ResultsViewModel.cs @@ -1,8 +1,6 @@ using System.Collections.ObjectModel; using System.Drawing; - using CommunityToolkit.Mvvm.ComponentModel; - using Typical.Core.Interfaces; using Typical.Core.Statistics; @@ -31,6 +29,7 @@ public void Initialize(TestResult result) } } } + public class GraphData { private readonly List _snapshots = new List(); @@ -40,16 +39,23 @@ public GraphData(IEnumerable snapshots) _snapshots = snapshots.ToList(); } - public List Points => _snapshots - .Select(s => new PointF((float)s.ElapsedTime.TotalSeconds, (float)s.WPM)).ToList(); + public List Points => + _snapshots + .Select(s => new PointF((float)s.ElapsedTime.TotalSeconds, (float)s.WPM)) + .ToList(); // ANALYZE: Provides the bounds for scaling public float MinWpm => _snapshots.Any() ? _snapshots.Min(s => (float)s.WPM) : 0; public float MaxWpm => _snapshots.Any() ? _snapshots.Max(s => (float)s.WPM) : 100; - public float TotalSeconds => _snapshots.Any() ? (float)_snapshots.Max(s => s.ElapsedTime.TotalSeconds) : 0; + public float TotalSeconds => + _snapshots.Any() ? (float)_snapshots.Max(s => s.ElapsedTime.TotalSeconds) : 0; // CALCULATE: Logic for the "Perfect Frame" - public (PointF CellSize, PointF ScrollOffset, float Increment) GetScale(Size viewport, uint marginLeft, uint marginBottom) + public (PointF CellSize, PointF ScrollOffset, float Increment) GetScale( + Size viewport, + uint marginLeft, + uint marginBottom + ) { float availableWidth = viewport.Width - marginLeft; float availableHeight = viewport.Height - marginBottom; @@ -67,5 +73,4 @@ public GraphData(IEnumerable snapshots) return (new PointF(cx, cy), new PointF(0, yMin), increment); } - } diff --git a/src/Typical.Core/ViewModels/SettingsViewModel.cs b/src/Typical.Core/ViewModels/SettingsViewModel.cs index 4ddeabe..86c4343 100644 --- a/src/Typical.Core/ViewModels/SettingsViewModel.cs +++ b/src/Typical.Core/ViewModels/SettingsViewModel.cs @@ -1,7 +1,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; + using Microsoft.Extensions.Logging; + using Typical.Core.Events; using Typical.Core.Interfaces; @@ -47,4 +49,3 @@ private void ShowRandomResult() //[RelayCommand] //private void Cancel() => _navService.NavigateTo(); } - diff --git a/src/Typical.Core/ViewModels/StatsViewModel.cs b/src/Typical.Core/ViewModels/StatsViewModel.cs index 6c47e75..3aaab93 100644 --- a/src/Typical.Core/ViewModels/StatsViewModel.cs +++ b/src/Typical.Core/ViewModels/StatsViewModel.cs @@ -25,7 +25,8 @@ public StatsViewModel(IMessenger messenger) public void Receive(TestSessionUpdatedMessage message) { Stats = message.Snapshot; - StatsLabel = $"Elapsed: {Stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(Stats.WPM.Value)} | Acc: {Stats.Accuracy.ToString()}"; + StatsLabel = + $"Elapsed: {Stats.ElapsedTime:mm\\:ss} | WPM: {Math.Round(Stats.WPM.Value)} | Acc: {Stats.Accuracy.ToString()}"; } [ObservableProperty] diff --git a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs index e9481e2..f59285e 100644 --- a/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/SimpleStatsRepository.cs @@ -1,5 +1,4 @@ using System.Diagnostics; - using Typical.Core.Data; using Typical.Core.Statistics; diff --git a/src/Typical/Services/NavigationService.cs b/src/Typical/Services/NavigationService.cs index d421dfe..a76790b 100644 --- a/src/Typical/Services/NavigationService.cs +++ b/src/Typical/Services/NavigationService.cs @@ -1,14 +1,10 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; - using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; - using Stanza.TerminalGui; - using Terminal.Gui.App; using Terminal.Gui.Views; - using Typical.Core.Events; using Typical.Core.Interfaces; using Typical.Navigation; @@ -87,7 +83,9 @@ public void NavigateTo(Action configure) // but typically for Modals, Dialog is the correct constraint. if (view is not Dialog dialog) { - throw new InvalidOperationException($"View for {typeof(TViewModel).Name} must be a Dialog."); + throw new InvalidOperationException( + $"View for {typeof(TViewModel).Name} must be a Dialog." + ); } void StopRequest(object? s, EventArgs e) => _app.RequestStop(); @@ -95,7 +93,7 @@ public void NavigateTo(Action configure) try { - // Terminal.Gui v2 logic: Application.Run works on any View, + // Terminal.Gui v2 logic: Application.Run works on any View, // but Dialogs provide the modal overlay/shadowing behavior. _app.Run(dialog); } diff --git a/src/Typical/UI/Views/ResultsView.cs b/src/Typical/UI/Views/ResultsView.cs index 104e2dd..b8bc287 100644 --- a/src/Typical/UI/Views/ResultsView.cs +++ b/src/Typical/UI/Views/ResultsView.cs @@ -1,12 +1,9 @@ using System.Drawing; - using Stanza.TerminalGui; - using Terminal.Gui.Drawing; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; - using Typical.Core.Statistics; using Typical.Core.ViewModels; @@ -60,18 +57,21 @@ public ResultsDialog(ResultsViewModel viewModel) _sourceLabel.Text = "Unknown"; _sourceLabel.Y = Pos.Bottom(_graph); Add(_sourceLabel); - AddButton(new() { Text = "_Ok" , X = Pos.Center()}); + AddButton(new() { Text = "_Ok", X = Pos.Center() }); ViewModel = viewModel; } + partial void OnApplyBindings(BindingContext context) { - if (ViewModel is null) return; + if (ViewModel is null) + return; var ctx = ViewModel.GraphContext; var data = ctx.Points; - if (!data.Any()) return; + if (!data.Any()) + return; // 1. Get the "Perfect Scale" from our data object var (cellSize, scrollOffset, yIncrement) = ctx.GetScale( @@ -96,9 +96,7 @@ partial void OnApplyBindings(BindingContext context) _graph.SetNeedsDraw(); - _sourceLabel.Text = ViewModel.Source; SetNeedsDraw(); } } - diff --git a/src/Typical/UI/Views/SettingsView.cs b/src/Typical/UI/Views/SettingsView.cs index 29b0a49..a6fdd4c 100644 --- a/src/Typical/UI/Views/SettingsView.cs +++ b/src/Typical/UI/Views/SettingsView.cs @@ -1,8 +1,6 @@ using Stanza.TerminalGui; - using Terminal.Gui.ViewBase; using Terminal.Gui.Views; - using Typical.Core.ViewModels; namespace Typical.UI.Views; diff --git a/src/Typical/UI/Views/StatsView.cs b/src/Typical/UI/Views/StatsView.cs index 53c8112..4475846 100644 --- a/src/Typical/UI/Views/StatsView.cs +++ b/src/Typical/UI/Views/StatsView.cs @@ -1,9 +1,7 @@ using Stanza.TerminalGui; - using Terminal.Gui.Drawing; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; - using Typical.Core.ViewModels; namespace Typical.UI.Views; @@ -27,9 +25,5 @@ public StatsView(StatsViewModel viewModel) ViewModel = viewModel; } - partial void OnApplyBindings(BindingContext context) - { - - } - + partial void OnApplyBindings(BindingContext context) { } } From 3c1d4a59a7034441cfbb1a5f1ecc938a4ed175bc Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:09:53 +0100 Subject: [PATCH 54/58] fix: improve exception handling in async voids --- src/Typical.Core/ViewModels/MainViewModel.cs | 34 +++++++++++++------ .../ViewModels/TypingViewModel.cs | 31 +++++++++++++---- src/Typical/Program.cs | 22 +++++++++--- src/Typical/Services/NavigationService.cs | 3 +- 4 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index 4748960..caf128e 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -75,20 +75,34 @@ public void Receive(TestCompletedMessage message) public async void Receive(TestResetMessage message) { - _navigationService.NavigateTo(vm => + try { - vm.InitializeAsync().Wait(); - }); + _navigationService.NavigateTo(vm => + { + vm.InitializeAsync().Wait(); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling test reset"); + } } public async void Receive(ShowResultDialogMessage message) { - TestResult result = await _statsRepository.GetTestResultAsync(); - _navigationService.ShowModal( - (vm) => - { - vm.Initialize(result); - } - ); + try + { + TestResult result = await _statsRepository.GetTestResultAsync(); + _navigationService.ShowModal( + (vm) => + { + vm.Initialize(result); + } + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error showing random result"); + } } } diff --git a/src/Typical.Core/ViewModels/TypingViewModel.cs b/src/Typical.Core/ViewModels/TypingViewModel.cs index a779a70..373db7a 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -36,10 +36,20 @@ IMessenger messenger ) { _Test = Test; - _Test.OnTestFinished += async (s, result) => await HandleTestFinished(result); _textProvider = textProvider; _statsRepository = statsRepository; _logger = logger; + _Test.OnTestFinished += async (s, result) => + { + try + { + await HandleTestFinished(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling test finished"); + } + }; _messenger = messenger; _refreshTimer = new Timer(100); @@ -53,15 +63,22 @@ IMessenger messenger /// public async void ProcessInput(string c, bool isBackspace) { - if (_Test.IsOver) + try { - await InitializeAsync(); - return; - } + if (_Test.IsOver) + { + await InitializeAsync(); + return; + } - bool accepted = _Test.ProcessKeyPress(c, isBackspace); + bool accepted = _Test.ProcessKeyPress(c, isBackspace); - UpdateState(); + UpdateState(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing input"); + } } /// diff --git a/src/Typical/Program.cs b/src/Typical/Program.cs index 679e5bf..fba574c 100644 --- a/src/Typical/Program.cs +++ b/src/Typical/Program.cs @@ -1,9 +1,14 @@ using System.Diagnostics.CodeAnalysis; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; + using Serilog; + using Stanza.TerminalGui; + using Terminal.Gui.App; + using Typical.Configuration; using Typical.Core.Services; using Typical.DataAccess; @@ -11,6 +16,7 @@ using Typical.Infrastructure; using Typical.Services; using Typical.UI.Views; + using Velopack; VelopackApp.Build().Run(); @@ -57,11 +63,17 @@ static async Task Run(IHost host) var migrator = host.Services.GetRequiredService(); await migrator.EnsureDatabaseUpdated(); - using var app = host.Services.GetRequiredService(); - app.Init(); + var app = host.Services.GetRequiredService(); - var mainShell = host.Services.GetRequiredService(); - app.Run(mainShell); + try + { + app.Init(); - app.Dispose(); + var mainShell = host.Services.GetRequiredService(); + app.Run(mainShell); + } + finally + { + try { app.Dispose(); } catch { } + } } diff --git a/src/Typical/Services/NavigationService.cs b/src/Typical/Services/NavigationService.cs index a76790b..aa02a1f 100644 --- a/src/Typical/Services/NavigationService.cs +++ b/src/Typical/Services/NavigationService.cs @@ -100,7 +100,8 @@ public void NavigateTo(Action configure) finally { vm.RequestClose -= StopRequest; - view.Dispose(); // Kills Stanza bindings + try { _app.RequestStop(); } catch { } + try { view.Dispose(); } catch { } } return vm.Result; From 329964998a87c60516b88c423b965abddffdb173 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:11:04 +0100 Subject: [PATCH 55/58] chore: formatting --- .../Events/StatisticsUpdatedMessage.cs | 15 +++++++++++---- src/Typical.Core/ViewModels/SettingsViewModel.cs | 2 -- src/Typical.Core/ViewModels/StatsViewModel.cs | 1 - src/Typical.DataAccess/Sqlite/StatsRepository.cs | 3 --- src/Typical/Program.cs | 12 +++++------- src/Typical/Services/NavigationService.cs | 12 ++++++++++-- 6 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/Typical.Core/Events/StatisticsUpdatedMessage.cs b/src/Typical.Core/Events/StatisticsUpdatedMessage.cs index 574abb1..465d3ae 100644 --- a/src/Typical.Core/Events/StatisticsUpdatedMessage.cs +++ b/src/Typical.Core/Events/StatisticsUpdatedMessage.cs @@ -2,15 +2,22 @@ namespace Typical.Core.Events; -public record TestSessionUpdatedMessage( - TestSnapshot Snapshot -); +public record TestSessionUpdatedMessage(TestSnapshot Snapshot); public record WordsMode(int Count, bool Punctuation, bool Numbers); + public record TimeMode(TimeSpan Duration, bool Punctuation, bool Numbers); + public record QuoteMode(QuoteLength Length); + public record ZenMode; // Empty marker record -public enum QuoteLength { All, Short, Medium, Long } +public enum QuoteLength +{ + All, + Short, + Medium, + Long, +} public union ModeSettings(WordsMode, TimeMode, QuoteMode, ZenMode); diff --git a/src/Typical.Core/ViewModels/SettingsViewModel.cs b/src/Typical.Core/ViewModels/SettingsViewModel.cs index 86c4343..db33211 100644 --- a/src/Typical.Core/ViewModels/SettingsViewModel.cs +++ b/src/Typical.Core/ViewModels/SettingsViewModel.cs @@ -1,9 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; - using Microsoft.Extensions.Logging; - using Typical.Core.Events; using Typical.Core.Interfaces; diff --git a/src/Typical.Core/ViewModels/StatsViewModel.cs b/src/Typical.Core/ViewModels/StatsViewModel.cs index 3aaab93..11b8d75 100644 --- a/src/Typical.Core/ViewModels/StatsViewModel.cs +++ b/src/Typical.Core/ViewModels/StatsViewModel.cs @@ -1,6 +1,5 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; - using Typical.Core.Events; using Typical.Core.Statistics; diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index 8e8719f..edd15f7 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -1,12 +1,9 @@ using System.ComponentModel.Design; using System.Reflection; using System.Text; - using Dapper; - using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; - using Typical.Core.Data; using Typical.Core.Statistics; using Typical.Core.Text; diff --git a/src/Typical/Program.cs b/src/Typical/Program.cs index fba574c..c95572f 100644 --- a/src/Typical/Program.cs +++ b/src/Typical/Program.cs @@ -1,14 +1,9 @@ using System.Diagnostics.CodeAnalysis; - using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; - using Serilog; - using Stanza.TerminalGui; - using Terminal.Gui.App; - using Typical.Configuration; using Typical.Core.Services; using Typical.DataAccess; @@ -16,7 +11,6 @@ using Typical.Infrastructure; using Typical.Services; using Typical.UI.Views; - using Velopack; VelopackApp.Build().Run(); @@ -74,6 +68,10 @@ static async Task Run(IHost host) } finally { - try { app.Dispose(); } catch { } + try + { + app.Dispose(); + } + catch { } } } diff --git a/src/Typical/Services/NavigationService.cs b/src/Typical/Services/NavigationService.cs index aa02a1f..e774e49 100644 --- a/src/Typical/Services/NavigationService.cs +++ b/src/Typical/Services/NavigationService.cs @@ -100,8 +100,16 @@ public void NavigateTo(Action configure) finally { vm.RequestClose -= StopRequest; - try { _app.RequestStop(); } catch { } - try { view.Dispose(); } catch { } + try + { + _app.RequestStop(); + } + catch { } + try + { + view.Dispose(); + } + catch { } } return vm.Result; From e820a1c91f97c0f5693cc9b558a877d93ca79761 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:20:07 +0100 Subject: [PATCH 56/58] sqlite error fixes --- .../Statistics/KeystrokeCollection.cs | 2 +- src/Typical.Core/ViewModels/MainViewModel.cs | 11 +- .../Migrations/202605230238_AddTestTables.sql | 17 +- .../Sqlite/StatsRepository.cs | 179 +++++++++++++----- src/Typical/Services/NavigationService.cs | 16 +- 5 files changed, 152 insertions(+), 73 deletions(-) diff --git a/src/Typical.Core/Statistics/KeystrokeCollection.cs b/src/Typical.Core/Statistics/KeystrokeCollection.cs index 17753a1..031a5a8 100644 --- a/src/Typical.Core/Statistics/KeystrokeCollection.cs +++ b/src/Typical.Core/Statistics/KeystrokeCollection.cs @@ -60,5 +60,5 @@ internal void Clear() internal IReadOnlyList GetLog() => _logs.AsReadOnly(); [Conditional("DEBUG")] - private void LogDebug(KeystrokeLog log) => Debug.WriteLine(log); + private void LogDebug(KeystrokeLog log) { }// Debug.WriteLine(log); } diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index caf128e..361d486 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -1,7 +1,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; + using Microsoft.Extensions.Logging; + using Typical.Core.Data; using Typical.Core.Events; using Typical.Core.Interfaces; @@ -47,7 +49,14 @@ IStatsRepository statsRepository _logger = logger; _messenger = messenger; - _messenger.Register(this, (r, m) => r.Receive(m)); + System.Diagnostics.Debug.WriteLine("MainViewModel constructor called"); + + _messenger.Register(this, (r, m) => + { + System.Diagnostics.Debug.WriteLine("TestCompletedMessage handler called"); + r.Receive(m); + }); + _messenger.Register(this, (r, m) => r.Receive(m)); _messenger.Register(this, (r, m) => r.Receive(m)); _statsRepository = statsRepository; diff --git a/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql index 46c7ae1..9160d4c 100644 --- a/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql +++ b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql @@ -10,25 +10,24 @@ CREATE TABLE Tests ( CustomText TEXT NULL ); +CREATE INDEX IX_Tests_QuoteId ON Tests (QuoteId); +CREATE INDEX IX_Tests_CreatedAt ON Tests (CreatedAt DESC); + CREATE TABLE KeystrokeTelemetry ( TestId INTEGER NOT NULL, OffsetMs INTEGER NOT NULL, GraphemeIndex INTEGER NOT NULL, - ActualText TEXT NOT NULL, + ActualText TEXT, KeystrokeType INTEGER NOT NULL, - - PRIMARY KEY (TestId, OffsetMs), FOREIGN KEY (TestId) REFERENCES Tests(Id) ON DELETE CASCADE -) WITHOUT ROWID; +); + +CREATE INDEX IX_KeystrokeTelemetry_TestId ON KeystrokeTelemetry(TestId); CREATE TABLE TestSnapshots ( TestId INTEGER NOT NULL, OffsetMs INTEGER NOT NULL, Wpm REAL NOT NULL, Accuracy REAL NOT NULL, - PRIMARY KEY (TestId, OffsetMs), FOREIGN KEY (TestId) REFERENCES Tests(Id) ON DELETE CASCADE -) WITHOUT ROWID; - -CREATE INDEX IX_Tests_QuoteId ON Tests (QuoteId); -CREATE INDEX IX_Tests_CreatedAt ON Tests (CreatedAt DESC); +); diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index edd15f7..07360f9 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -1,9 +1,12 @@ using System.ComponentModel.Design; using System.Reflection; using System.Text; + using Dapper; + using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; + using Typical.Core.Data; using Typical.Core.Statistics; using Typical.Core.Text; @@ -43,6 +46,7 @@ public async Task GetTestResultAsync(int? id = null) int maxId = await connection.ExecuteScalarAsync(@"SELECT MAX(id) FROM Tests;"); id = Random.Shared.Next(1, maxId + 1); } + var testIdParam = new { testId = id }; const string testSql = @" @@ -61,9 +65,9 @@ FROM Tests t WHERE t.Id = @testId; "; - var testRow = await connection.QueryFirstOrDefaultAsync( + var testRow = await connection.QueryFirstOrDefaultAsync( testSql, - new { testId = id } + testIdParam ); const string snapshotsSql = @@ -71,14 +75,19 @@ FROM Tests t SELECT * FROM TestSnapshots WHERE TestId = @testId ORDER BY OffsetMs ASC; "; - var snapshots = ( - await connection.QueryAsync(snapshotsSql, new { testId = id }) - ).ToList(); + var snapshotDtos = await connection.QueryAsync(snapshotsSql, testIdParam); + var snapshots = snapshotDtos.Select(dto => dto.ToDomain()).ToList(); + const string telemetrySql = @" +SELECT * FROM KeystrokeTelemetry kt +WHERE kt.TestId = @testId; +"; - TextSample sample; - if (testRow?.QuoteId is not null) - { - sample = new TextSample + var telemetryDto = await connection.QueryAsync(telemetrySql, testIdParam); + var telemetry = telemetryDto.Select(row => row.ToDomain()).ToList(); + + TextSample sample = testRow?.QuoteId is null + ? TextSample.Empty + : new TextSample { SourceId = testRow.QuoteId, Text = testRow.QuoteText!, @@ -86,19 +95,13 @@ FROM Tests t WordCount = testRow.WordCount, CharCount = testRow.CharCount, }; - } - else - { - sample = TextSample.Empty; - } - return new TestResult( - PlayedAt: DateTimeOffset.FromUnixTimeSeconds(testRow!.CreatedAt).DateTime, + PlayedAt: DateTimeOffset.FromUnixTimeMilliseconds(testRow!.CreatedAt).DateTime, FinalWpm: Wpm.From(testRow.Wpm), FinalAccuracy: Accuracy.From(testRow.Accuracy), Duration: TimeSpan.FromMilliseconds(testRow.DurationMs), Target: sample, - Telemetry: [], + Telemetry: telemetry, Snapshots: snapshots, RawWpm: Wpm.From(testRow.Wpm) ); @@ -116,6 +119,22 @@ INSERT INTO Tests (CreatedAt, Wpm, RawWpm, Accuracy, DurationMs, QuoteId, Custom SELECT last_insert_rowid(); """; + // Create an anonymous object for the parameters. + // Dapper.AOT unpacks this at compile time into raw ADO.NET assignments. + var args = new + { + CreatedAt = new DateTimeOffset(result.PlayedAt).ToUnixTimeMilliseconds(), + Wpm = result.FinalWpm.Value, // Unwrap Vogen + RawWpm = result.RawWpm.Value, // Unwrap Vogen + Accuracy = result.FinalAccuracy.Value, // Unwrap Vogen + DurationMs = (long)result.Duration.TotalMilliseconds, + QuoteId = result.Target.SourceId, + CustomText = result.Target.SourceId.HasValue ? null : result.Target.Text + }; + + // Use ExecuteScalarAsync to grab the newly inserted ID + return await conn.ExecuteScalarAsync(sql, args, trans); + await using var cmd = new SqliteCommand(sql, conn, trans); cmd.Parameters.AddWithValue( @@ -139,6 +158,7 @@ INSERT INTO Tests (CreatedAt, Wpm, RawWpm, Accuracy, DurationMs, QuoteId, Custom } return (long)(await cmd.ExecuteScalarAsync() ?? 0L); + } private async Task InsertTelemetryAsync( @@ -153,24 +173,16 @@ INSERT INTO KeystrokeTelemetry (TestId, OffsetMs, GraphemeIndex, ActualText, Key VALUES (@TestId, @OffsetMs, @Index, @Actual, @Type); """; - await using var cmd = new SqliteCommand(sql, conn, trans); - var pTestId = cmd.Parameters.Add("@TestId", SqliteType.Integer); - var pOffset = cmd.Parameters.Add("@OffsetMs", SqliteType.Integer); - var pIndex = cmd.Parameters.Add("@Index", SqliteType.Integer); - var pActual = cmd.Parameters.Add("@Actual", SqliteType.Text); - var pType = cmd.Parameters.Add("@Type", SqliteType.Integer); - - pTestId.Value = testId; - - foreach (var log in result.Telemetry) + var args = result.Telemetry.Select(log => new TelemetryInsertArgs { - pOffset.Value = log.Timestamp; - pIndex.Value = log.Index; - pActual.Value = log.Value; - pType.Value = (int)log.Type; - - await cmd.ExecuteNonQueryAsync(); - } + TestId = testId, + OffsetMs = log.OffsetMs, + GraphemeIndex = log.Index, + ActualText = log.Value, + KeystrokeType = (int)log.Type // Cast enum to int + }); + + await conn.ExecuteAsync(sql, args, trans); } private async Task InsertSnapshotsAsync( @@ -188,24 +200,17 @@ INSERT INTO TestSnapshots (TestId, OffsetMs, Wpm, Accuracy) VALUES (@TestId, @OffsetMs, @Wpm, @Acc); """; - await using var cmd = new SqliteCommand(sql, conn, trans); - var pTestId = cmd.Parameters.Add("@TestId", SqliteType.Integer); - var pOffset = cmd.Parameters.Add("@OffsetMs", SqliteType.Integer); - var pWpm = cmd.Parameters.Add("@Wpm", SqliteType.Real); - var pAcc = cmd.Parameters.Add("@Acc", SqliteType.Real); - - pTestId.Value = testId; - - foreach (var snap in snapshots) + // Project your domain models to the flattened primitive args + var args = snapshots.Select(snap => new SnapshotInsertArgs { - pTestId.Value = testId; - pOffset.Value = (long)snap.ElapsedTime.TotalMilliseconds; - - pWpm.Value = snap.WPM.Value; - pAcc.Value = snap.Accuracy.Value; - - await cmd.ExecuteNonQueryAsync(); - } + TestId = testId, + OffsetMs = (long)snap.ElapsedTime.TotalMilliseconds, + Wpm = snap.WPM.Value, // Unwrap + Accuracy = snap.Accuracy.Value // Unwrap + }); + + // Pass the IEnumerable directly! Dapper.AOT handles the iteration internally. + await conn.ExecuteAsync(sql, args, trans); } private async Task GetConnectionAsync() @@ -216,7 +221,7 @@ private async Task GetConnectionAsync() } } -internal class TestRow +internal class TestDto { public float Wpm { get; set; } public long CreatedAt { get; set; } @@ -228,3 +233,73 @@ internal class TestRow public int CharCount { get; set; } public int WordCount { get; set; } } + +[DapperAot] +internal class TestSnapshotDto +{ + public long TestId { get; set; } + public long OffsetMs { get; set; } + public double Wpm { get; set; } + [DbValue(Name = "Acc")] + public double Accuracy { get; set; } + + internal TestSnapshot ToDomain() + { + return new TestSnapshot( + Core.Statistics.Wpm.From(Wpm), + Core.Statistics.Accuracy.From(Accuracy), + new TestMetrics(0, 0, 0), // Metrics aren't stored in the snapshot table + TimeSpan.FromMilliseconds(OffsetMs) + ); + } +} + +[DapperAot] +internal class KeystrokeTelemetryDto +{ + public long TestId { get; set; } + public long OffsetMs { get; set; } + public int GraphemeIndex { get; set; } + public string? ActualText { get; set; } + public int KeystrokeType { get; set; } + + internal KeystrokeLog ToDomain() + { + return new KeystrokeLog( + Value: ActualText ?? string.Empty, + Type: (KeystrokeType)KeystrokeType, // Cast the raw int back to the enum + + // Note: In InsertTelemetryAsync you save log.Timestamp into the @OffsetMs parameter. + // We map it back to both here so the domain model stays fully populated. + Timestamp: OffsetMs, + OffsetMs: OffsetMs, + + Index: GraphemeIndex + ); + } +} +[DapperAot] +internal struct SnapshotInsertArgs +{ + public long TestId { get; set; } + public long OffsetMs { get; set; } + public double Wpm { get; set; } + + [DbValue(Name = "Acc")] // Maps to the @Acc parameter in your SQL + public double Accuracy { get; set; } +} +[DapperAot] +internal struct TelemetryInsertArgs +{ + public long TestId { get; set; } + public long OffsetMs { get; set; } + + [DbValue(Name = "Index")] + public int GraphemeIndex { get; set; } + + [DbValue(Name = "Actual")] + public string ActualText { get; set; } + + [DbValue(Name = "Type")] + public int KeystrokeType { get; set; } +} diff --git a/src/Typical/Services/NavigationService.cs b/src/Typical/Services/NavigationService.cs index e774e49..227ebc0 100644 --- a/src/Typical/Services/NavigationService.cs +++ b/src/Typical/Services/NavigationService.cs @@ -1,10 +1,14 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; + using Stanza.TerminalGui; + using Terminal.Gui.App; using Terminal.Gui.Views; + using Typical.Core.Events; using Typical.Core.Interfaces; using Typical.Navigation; @@ -79,8 +83,6 @@ public void NavigateTo(Action configure) stanzaView.ViewModel = vm; } - // Differentiate only if you want to support non-Dialog Windows (standard top-level views) - // but typically for Modals, Dialog is the correct constraint. if (view is not Dialog dialog) { throw new InvalidOperationException( @@ -93,21 +95,15 @@ public void NavigateTo(Action configure) try { - // Terminal.Gui v2 logic: Application.Run works on any View, - // but Dialogs provide the modal overlay/shadowing behavior. _app.Run(dialog); } finally { vm.RequestClose -= StopRequest; + try { - _app.RequestStop(); - } - catch { } - try - { - view.Dispose(); + dialog.Dispose(); } catch { } } From 9f3601d4a1e35b3b3b5b49daabc40ba17cefd9f1 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+henry-js@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:53:59 +0100 Subject: [PATCH 57/58] chore: add typical exception --- .../Exceptions/TypicalException.cs | 17 ++++++++++++ src/Typical.Core/ViewModels/MainViewModel.cs | 5 ++++ .../ViewModels/SettingsViewModel.cs | 5 ++-- .../Sqlite/StatsRepository.cs | 27 +++++++++++++++++-- src/Typical/Program.cs | 7 +++++ 5 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 src/Typical.Core/Exceptions/TypicalException.cs diff --git a/src/Typical.Core/Exceptions/TypicalException.cs b/src/Typical.Core/Exceptions/TypicalException.cs new file mode 100644 index 0000000..70eb2b9 --- /dev/null +++ b/src/Typical.Core/Exceptions/TypicalException.cs @@ -0,0 +1,17 @@ +namespace Typical.Core.Exceptions; + +[Serializable] +public class TypicalException : Exception +{ + public TypicalException() + { + } + + public TypicalException(string? message) : base(message) + { + } + + public TypicalException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/src/Typical.Core/ViewModels/MainViewModel.cs b/src/Typical.Core/ViewModels/MainViewModel.cs index 361d486..0efd8fc 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -6,6 +6,7 @@ using Typical.Core.Data; using Typical.Core.Events; +using Typical.Core.Exceptions; using Typical.Core.Interfaces; using Typical.Core.Statistics; using Typical.Core.Text; @@ -109,6 +110,10 @@ public async void Receive(ShowResultDialogMessage message) } ); } + catch (TypicalException tex) + { + _logger.LogError(tex, "Application Error"); + } catch (Exception ex) { _logger.LogError(ex, "Error showing random result"); diff --git a/src/Typical.Core/ViewModels/SettingsViewModel.cs b/src/Typical.Core/ViewModels/SettingsViewModel.cs index db33211..60dd17e 100644 --- a/src/Typical.Core/ViewModels/SettingsViewModel.cs +++ b/src/Typical.Core/ViewModels/SettingsViewModel.cs @@ -1,7 +1,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; + using Microsoft.Extensions.Logging; + using Typical.Core.Events; using Typical.Core.Interfaces; @@ -43,7 +45,4 @@ private void ShowRandomResult() var message = new ShowResultDialogMessage(Random: true); _messenger.Send(message); } - - //[RelayCommand] - //private void Cancel() => _navService.NavigateTo(); } diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index 07360f9..a988232 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Options; using Typical.Core.Data; +using Typical.Core.Exceptions; using Typical.Core.Statistics; using Typical.Core.Text; @@ -43,8 +44,13 @@ public async Task GetTestResultAsync(int? id = null) if (id is null) { - int maxId = await connection.ExecuteScalarAsync(@"SELECT MAX(id) FROM Tests;"); - id = Random.Shared.Next(1, maxId + 1); + int? maxId = await connection.ExecuteScalarAsync(@"SELECT MAX(id) FROM Tests;"); + + if (maxId is null) + { + throw new TypicalException(); + } + id = Random.Shared.Next(1, maxId.Value + 1); } var testIdParam = new { testId = id }; @@ -221,6 +227,23 @@ private async Task GetConnectionAsync() } } + +[Serializable] +public class NoTestsExistException : TypicalException +{ + public NoTestsExistException() + { + } + + public NoTestsExistException(string? message) : base(message) + { + } + + public NoTestsExistException(string? message, Exception? innerException) : base(message, innerException) + { + } +} + internal class TestDto { public float Wpm { get; set; } diff --git a/src/Typical/Program.cs b/src/Typical/Program.cs index c95572f..c238d69 100644 --- a/src/Typical/Program.cs +++ b/src/Typical/Program.cs @@ -1,9 +1,14 @@ using System.Diagnostics.CodeAnalysis; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; + using Serilog; + using Stanza.TerminalGui; + using Terminal.Gui.App; + using Typical.Configuration; using Typical.Core.Services; using Typical.DataAccess; @@ -11,6 +16,7 @@ using Typical.Infrastructure; using Typical.Services; using Typical.UI.Views; + using Velopack; VelopackApp.Build().Run(); @@ -73,5 +79,6 @@ static async Task Run(IHost host) app.Dispose(); } catch { } + Environment.Exit(1); } } From bcdee747d195bd837b63e0ac7471e01c58acc585 Mon Sep 17 00:00:00 2001 From: jamesshenry Date: Thu, 25 Jun 2026 10:48:53 +0100 Subject: [PATCH 58/58] fix build warnings and errors --- src/Directory.Packages.props | 2 +- .../Sqlite/StatsRepository.cs | 25 ------------------- src/Typical/Typical.csproj | 2 +- 3 files changed, 2 insertions(+), 27 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index cac7110..0451d6e 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -16,7 +16,7 @@ - + diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs index a988232..f2017de 100644 --- a/src/Typical.DataAccess/Sqlite/StatsRepository.cs +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -140,31 +140,6 @@ INSERT INTO Tests (CreatedAt, Wpm, RawWpm, Accuracy, DurationMs, QuoteId, Custom // Use ExecuteScalarAsync to grab the newly inserted ID return await conn.ExecuteScalarAsync(sql, args, trans); - - await using var cmd = new SqliteCommand(sql, conn, trans); - - cmd.Parameters.AddWithValue( - "@CreatedAt", - new DateTimeOffset(result.PlayedAt).ToUnixTimeMilliseconds() - ); - cmd.Parameters.AddWithValue("@Wpm", result.FinalWpm.Value); - cmd.Parameters.AddWithValue("@RawWpm", result.RawWpm.Value); - cmd.Parameters.AddWithValue("@Accuracy", result.FinalAccuracy.Value); - cmd.Parameters.AddWithValue("@DurationMs", (long)result.Duration.TotalMilliseconds); - - if (result.Target.SourceId.HasValue) - { - cmd.Parameters.AddWithValue("@QuoteId", result.Target.SourceId.Value); - cmd.Parameters.AddWithValue("@CustomText", DBNull.Value); - } - else - { - cmd.Parameters.AddWithValue("@QuoteId", DBNull.Value); - cmd.Parameters.AddWithValue("@CustomText", result.Target.Text); - } - - return (long)(await cmd.ExecuteScalarAsync() ?? 0L); - } private async Task InsertTelemetryAsync( diff --git a/src/Typical/Typical.csproj b/src/Typical/Typical.csproj index 1ff2d42..ba2ec48 100644 --- a/src/Typical/Typical.csproj +++ b/src/Typical/Typical.csproj @@ -22,7 +22,7 @@ - +