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). 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/.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/.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/.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 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8002fe7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[csharp]": { + "editor.defaultFormatter": "ms-dotnettools.csharp" + } +} 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/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/Typical.slnx b/Typical.slnx index 603823d..b505f44 100644 --- a/Typical.slnx +++ b/Typical.slnx @@ -1,6 +1,15 @@ + + + + + + + + + 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/nuget.config b/nuget.config index 6ce9759..2805dff 100644 --- a/nuget.config +++ b/nuget.config @@ -1,4 +1,4 @@ - + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index dea7cf0..eca7ff2 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -7,6 +7,8 @@ enable embedded $(MSBuildThisFileDirectory)..\.artifacts + true + runtime-async=on diff --git a/Directory.Packages.props b/src/Directory.Packages.props similarity index 85% rename from Directory.Packages.props rename to src/Directory.Packages.props index 81476e7..3606863 100644 --- a/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -7,6 +7,8 @@ + + @@ -14,21 +16,21 @@ - - - - + + + + - - - + + + @@ -45,12 +47,13 @@ + - + - + diff --git a/src/Typical.Core/Data/IStatsRepository.cs b/src/Typical.Core/Data/IStatsRepository.cs new file mode 100644 index 0000000..efd1b4b --- /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 SaveTestResultAsync(TestResult result); +} diff --git a/src/Typical.Core/Data/ITextRepository.cs b/src/Typical.Core/Data/ITextRepository.cs new file mode 100644 index 0000000..e916805 --- /dev/null +++ b/src/Typical.Core/Data/ITextRepository.cs @@ -0,0 +1,8 @@ +namespace Typical.Core.Data; + +public interface ITextRepository +{ + Task GetRandomQuoteAsync(); + Task GetQuoteAsync(int currentId); + Task HasAnyAsync(); +} diff --git a/src/Typical.Core/Data/Quote.cs b/src/Typical.Core/Data/Quote.cs index 271ee42..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,15 +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; } } - -public interface ITextRepository -{ - Task GetRandomQuoteAsync(); - Task GetQuoteAsync(int currentId); - Task AddQuotesAsync(IEnumerable quotes); - Task HasAnyAsync(); -} 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.Core/Events/GameStateUpdatedEvent.cs b/src/Typical.Core/Events/StatisticsUpdatedMessage.cs similarity index 78% rename from src/Typical.Core/Events/GameStateUpdatedEvent.cs rename to src/Typical.Core/Events/StatisticsUpdatedMessage.cs index 13d95d5..574abb1 100644 --- a/src/Typical.Core/Events/GameStateUpdatedEvent.cs +++ b/src/Typical.Core/Events/StatisticsUpdatedMessage.cs @@ -2,12 +2,10 @@ namespace Typical.Core.Events; -public record GameStateUpdatedMessage( - GameSnapshot State +public record TestSessionUpdatedMessage( + TestSnapshot Snapshot ); -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/TestCompletedMessage.cs b/src/Typical.Core/Events/TestCompletedMessage.cs new file mode 100644 index 0000000..134f4d9 --- /dev/null +++ b/src/Typical.Core/Events/TestCompletedMessage.cs @@ -0,0 +1,5 @@ +using Typical.Core.Statistics; + +namespace Typical.Core.Events; + +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/GameEngine.cs b/src/Typical.Core/GameEngine.cs deleted file mode 100644 index bae4881..0000000 --- a/src/Typical.Core/GameEngine.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.Diagnostics; -using System.Text; -using Microsoft.Extensions.Logging; -using Typical.Core.Events; -using Typical.Core.Logging; -using Typical.Core.Statistics; -using Typical.Core.Text; - -namespace Typical.Core; - -public class GameEngine -{ - private readonly StringBuilder _userInput = new(); - private readonly GameOptions _gameOptions; - - // TODO: Add HeatmapCollector - private readonly ILogger _logger; - - private KeystrokeType[] _charStates = []; - public IReadOnlyList CharacterStates => _charStates; - - public GameEngine(GameOptions gameOptions, ILogger logger) - { - _gameOptions = gameOptions; - Stats = new GameStats(); - _logger = logger; - } - - public GameStats Stats { get; private set; } - public string TargetText { get; private set; } = string.Empty; - - 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(TargetText, UserInput, IsOver); - - public bool ProcessKeyPress(char c, bool isBackspace) - { - if (!IsRunning && !IsOver && TargetText.Length > 0 && !isBackspace) - { - Stats.Start(); - CoreLogs.GameStarting(_logger); - } - - int currentPos = _userInput.Length; - - if (isBackspace) - { - if (currentPos > 0) - { - _userInput.Remove(currentPos - 1, 1); - _charStates[currentPos - 1] = KeystrokeType.Untyped; - Stats.RecordBackspace(); - } - return true; - } - - if (currentPos >= TargetText.Length) - return false; - - var type = DetermineKeystrokeType(c); - Stats.RecordKey(c, type); - - bool isCorrect = type == KeystrokeType.Correct; - if (!_gameOptions.ForbidIncorrectEntries || isCorrect) - { - _userInput.Append(c); - _charStates[currentPos] = type; - } - else - { - return false; - } - - 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 (_userInput.ToString().Equals(TargetText) || !_gameOptions.Require100Accuracy) - { - IsOver = true; - Stats.Stop(); - } - } - } - - public void LoadText(TextSample sample) - { - var text = sample.Text; - TargetText = text; - _userInput.Clear(); - _charStates = new KeystrokeType[text.Length]; - Array.Fill(_charStates, KeystrokeType.Untyped); - - IsOver = false; - Stats = new GameStats(); - } -} 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/Logging/CoreLogs.cs b/src/Typical.Core/Logging/CoreLogs.cs index c0a582e..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, GameSnapshot 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/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 45c9ea9..6a8c4fb 100644 --- a/src/Typical.Core/Services/ServiceExtensions.cs +++ b/src/Typical.Core/Services/ServiceExtensions.cs @@ -8,14 +8,16 @@ public static class ServiceExtensions { public static void AddCoreServices(this IServiceCollection services) { + services.AddMessenger(); services.AddSingleton(TimeProvider.System); - services.AddSingleton(); - services.AddSingleton(GameOptions.Default); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(TestOptions.Default); + services.AddSingleton(); services.AddSingleton(); - services.AddTransient(); + services.AddSingleton(); services.AddTransient(); - services.AddTransient(); + //services.AddTransient(); 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 f8675e5..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 Extra, int Corrections); diff --git a/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs b/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs deleted file mode 100644 index 339ceee..0000000 --- a/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Vogen; - -namespace Typical.Core.Statistics; - -public readonly record struct GameSnapshot( - double WordsPerMinute, - Accuracy Accuracy, - CharacterStats Chars, - TimeSpan ElapsedTime, - bool IsRunning, - string TargetText, - string UserInput, - bool IsOver -) -{ - public static GameSnapshot Empty => - new( - 0, - Accuracy.From(100), - new CharacterStats(0, 0, 0, 0), - TimeSpan.Zero, - false, - "", - "", - true - ); -} - -[ValueObject] -public partial struct Accuracy -{ - public override string ToString() - { - return $"{Value:F1}"; - } -} diff --git a/src/Typical.Core/Statistics/GameStats.cs b/src/Typical.Core/Statistics/GameStats.cs deleted file mode 100644 index e481c71..0000000 --- a/src/Typical.Core/Statistics/GameStats.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Diagnostics; - -namespace Typical.Core.Statistics; - -public class GameStats -{ - private readonly TimeProvider _timeProvider; - private readonly List _logs = []; - - // Running Totals (State) - private int _correctCount; - private int _incorrectCount; - private int _extraCount; - private int _correctionCount; - private long? _startTimestamp; - private long? _endTimestamp; - - 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.Extra: - _extraCount += change; - break; - case KeystrokeType.Correction: - _correctionCount += change; - break; - } - } - - internal void RecordKey(char c, KeystrokeType type) - { - if (!IsRunning) - Start(); - - UpdateCounts(type, 1); - _logs.AddAndDebug(new KeystrokeLog(c, type, _timeProvider.GetTimestamp())); - } - - 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()) - ); - } - - internal void Start() => _startTimestamp = _timeProvider.GetTimestamp(); - - internal void Stop() => _endTimestamp = _timeProvider.GetTimestamp(); - - public GameSnapshot CreateSnapshot(string targetText, string userInput, bool isOver) - { - 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 - ); - } - - public TimeSpan ElapsedTime => - _startTimestamp.HasValue - ? _timeProvider.GetElapsedTime( - _startTimestamp.Value, - _endTimestamp ?? _timeProvider.GetTimestamp() - ) - : TimeSpan.Zero; - - public bool IsRunning => _startTimestamp.HasValue && !_endTimestamp.HasValue; - - public IReadOnlyList GetHistory() => _logs.AsReadOnly(); -} - -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/KeystrokeCollection.cs b/src/Typical.Core/Statistics/KeystrokeCollection.cs new file mode 100644 index 0000000..17753a1 --- /dev/null +++ b/src/Typical.Core/Statistics/KeystrokeCollection.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; + +namespace Typical.Core.Statistics; + +public class KeystrokeCollection +{ + private readonly List _logs = new(); + private long? _firstTimestamp; + + public int CorrectCount { get; private set; } + public int TotalPhysical => _logs.Count; + public int ErrorCount { get; private set; } + public int CorrectionCount { get; private set; } + + /// 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) + { + _firstTimestamp ??= timestamp; + + 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) + { + case KeystrokeType.Correct: + CorrectCount++; + break; + case KeystrokeType.Incorrect: + ErrorCount++; + break; + case KeystrokeType.Correction: + CorrectionCount++; + break; + } + + LogDebug(log); + } + + internal void Clear() + { + _logs.Clear(); + _firstTimestamp = null; + CorrectCount = 0; + ErrorCount = 0; + CorrectionCount = 0; + } + + 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 0c94f2c..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(char Character, KeystrokeType Type, long Timestamp); +public record struct KeystrokeLog( + string Value, + KeystrokeType Type, + long Timestamp, + long OffsetMs, + int Index +); 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.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/TestResult.cs b/src/Typical.Core/Statistics/TestResult.cs new file mode 100644 index 0000000..a24fd43 --- /dev/null +++ b/src/Typical.Core/Statistics/TestResult.cs @@ -0,0 +1,14 @@ +using Typical.Core.Text; + +namespace Typical.Core.Statistics; + +public readonly record struct TestResult( + DateTime PlayedAt, + Wpm FinalWpm, + Accuracy FinalAccuracy, + TimeSpan Duration, + TextSample Target, + IReadOnlyList Telemetry, + IReadOnlyList Snapshots, + Wpm RawWpm +); diff --git a/src/Typical.Core/Statistics/TestSession.cs b/src/Typical.Core/Statistics/TestSession.cs new file mode 100644 index 0000000..15cee04 --- /dev/null +++ b/src/Typical.Core/Statistics/TestSession.cs @@ -0,0 +1,95 @@ +using Typical.Core.Text; + +namespace Typical.Core.Statistics; + +public class TestSession +{ + private readonly TimeProvider _timeProvider; + private readonly KeystrokeCollection _keystrokes = new(); + private readonly List _snapshots = []; + private long? _startTimestamp; + private long? _endTimestamp; + + public TestSession(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + } + + 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, int currentIndex) + { + if (!IsRunning) + Start(); + + _keystrokes.Add(grapheme, type, _timeProvider.GetTimestamp(), currentIndex); + } + + internal void RecordBackspace(int currentIndex) + { + _keystrokes.Add("\b", KeystrokeType.Correction, _timeProvider.GetTimestamp(), currentIndex); + } + + internal void Start() + { + Reset(); + _startTimestamp = _timeProvider.GetTimestamp(); + } + + private void Reset() + { + _startTimestamp = null; + _endTimestamp = null; + _keystrokes.Clear(); + _snapshots.Clear(); + } + + internal void Stop() => _endTimestamp = _timeProvider.GetTimestamp(); + + public TestSnapshot GetCurrentSnapshot() + { + var characterStats = new TestMetrics( + _keystrokes.CorrectCount, + _keystrokes.ErrorCount, + _keystrokes.CorrectionCount + ); + return TestSnapshot.Create(characterStats, ElapsedTime); + } + + public void SampleSnapshot() + { + var snapshot = GetCurrentSnapshot(); + if (_snapshots.Count > 0 && _snapshots[^1].ElapsedTime == snapshot.ElapsedTime) + { + return; + } + _snapshots.Add(snapshot); + } + + internal TestResult GetFinalResult(TextSample targetSample) + { + var finalSnapshot = GetCurrentSnapshot(); + 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/TestSnapshot.cs b/src/Typical.Core/Statistics/TestSnapshot.cs new file mode 100644 index 0000000..c6fc79c --- /dev/null +++ b/src/Typical.Core/Statistics/TestSnapshot.cs @@ -0,0 +1,30 @@ +using System.Diagnostics; + +namespace Typical.Core.Statistics; + +public readonly record struct TestSnapshot( + Wpm WPM, + Accuracy Accuracy, + TestMetrics Metrics, + TimeSpan ElapsedTime +) +{ + 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; + double minutes = elapsed.TotalMinutes; + double wpmValue = (minutes <= 0) ? 0 : (chars.Correct / 5.0) / minutes; + + var snapshot = new TestSnapshot( + Wpm.From(Math.Max(0, wpmValue)), + Accuracy.From(Math.Clamp(accValue, 0, 100)), + chars, + elapsed + ); + return snapshot; + } + + public static TestSnapshot Empty => + 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.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/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.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/Text/TypingBuffer.cs b/src/Typical.Core/Text/TypingBuffer.cs new file mode 100644 index 0000000..f648a5a --- /dev/null +++ b/src/Typical.Core/Text/TypingBuffer.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace Typical.Core.Text; + +public class TypingBuffer +{ + private readonly StringBuilder _buffer = new(); + + private readonly List _graphemes = new(); + public int GraphemeCount => _graphemes.Count; + public int Length => _buffer.Length; + + public void Push(string grapheme) + { + _buffer.Append(grapheme); + _graphemes.Add(grapheme); + } + + public string Pop() + { + var last = _graphemes[^1]; + _buffer.Remove(_buffer.Length - last.Length, last.Length); + _graphemes.RemoveAt(_graphemes.Count - 1); + return last; + } + + public void Clear() + { + _buffer.Clear(); + _graphemes.Clear(); + } + + public override string ToString() => _buffer.ToString(); + + internal string GetGraphemeAt(int index) => _graphemes[index]; +} 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.Core/TypingTest.cs b/src/Typical.Core/TypingTest.cs new file mode 100644 index 0000000..3732359 --- /dev/null +++ b/src/Typical.Core/TypingTest.cs @@ -0,0 +1,131 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Extensions.Logging; +using Typical.Core.Events; +using Typical.Core.Logging; +using Typical.Core.Statistics; +using Typical.Core.Text; + +namespace Typical.Core; + +public class TypingTest +{ + private readonly TypingBuffer _userInput = new(); + private string[] _targetGraphemes = []; + private readonly TestOptions _testOptions; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + public event EventHandler? OnTestFinished; + + public TypingTest( + TestOptions testOptions, + ILogger logger, + TimeProvider timeProvider + ) + { + _testOptions = testOptions; + _timeProvider = timeProvider; + Stats = new TestSession(_timeProvider); + _logger = logger; + } + + internal TestSession Stats { get; private set; } + public TextSample TargetSample { get; private set; } = TextSample.Empty; + public string TargetText { get; private set; } = string.Empty; + + public string UserInput => _userInput.ToString(); + public bool IsOver { get; private set; } + public bool IsRunning => !IsOver && Stats.IsRunning; + + public TextSample SampleNormalized { get; private set; } = TextSample.Empty; + + public TestSnapshot GetCurrentSnapshot() => Stats.GetCurrentSnapshot(); + + public bool ProcessKeyPress(string input, bool isBackspace) + { + if (!IsRunning && !IsOver && !isBackspace) + { + Stats.Start(); + CoreLogs.TestStarting(_logger); + } + + if (isBackspace) + { + if (_userInput.GraphemeCount > 0) + { + _userInput.Pop(); + + Stats.RecordBackspace(_userInput.GraphemeCount); + } + 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, _userInput.GraphemeCount); + + if (!_testOptions.ForbidIncorrectEntries || isCorrect) + { + _userInput.Push(normalizedInput); + CheckEndCondition(); + } + + return true; + } + + private void CheckEndCondition() + { + if (_userInput.GraphemeCount == _targetGraphemes.Length) + { + if (_testOptions.Require100Accuracy && _userInput.ToString() != TargetText) + { + return; + } + + Stats.Stop(); + IsOver = true; + var result = Stats.GetFinalResult(TargetSample); + + OnTestFinished?.Invoke(this, result); + } + } + + public void LoadText(TextSample sample) + { + TargetSample = sample; + TargetText = sample.Text.Normalize(NormalizationForm.FormC); + SampleNormalized = sample with { Text = sample.Text.Normalize(NormalizationForm.FormC) }; + + List list = []; + var enumerator = StringInfo.GetTextElementEnumerator(TargetText); + enumerator.Reset(); + while (enumerator.MoveNext()) + { + list.Add(enumerator.GetTextElement()); + } + + _targetGraphemes = list.ToArray(); + _userInput.Clear(); + + IsOver = false; + Stats = new TestSession(_timeProvider); + } + + 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/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 f760996..b6b8622 100644 --- a/src/Typical.Core/ViewModels/MainViewModel.cs +++ b/src/Typical.Core/ViewModels/MainViewModel.cs @@ -4,14 +4,16 @@ 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 { private readonly INavigationService _navigationService; private readonly IDialogService _dialogService; private readonly ILogger _logger; + private readonly IMessenger _messenger; [ObservableProperty] public partial string AppTitle { get; set; } = "Typical"; @@ -25,24 +27,31 @@ public sealed partial class MainViewModel : ObservableObject, IRecipient logger + ILogger logger, + IMessenger messenger ) { _navigationService = navigationService; + _navigationService.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(navigationService.CurrentViewModel)) + { + CurrentPage = _navigationService.CurrentViewModel; + } + }; _dialogService = dialogService; _logger = logger; + _messenger = messenger; - WeakReferenceMessenger.Default.Register( - this, - (r, m) => r.Receive(m) - ); + _messenger.Register(this, (r, m) => r.Receive(m)); + _messenger.Register(this, (r, m) => r.Receive(m)); } [RelayCommand] - private void NavigateToGameView() => _navigationService.NavigateTo(); + private void NavigateToTestView() => _navigationService.NavigateTo(); - [RelayCommand] - private void NavigateHome() => _navigationService.NavigateTo(); + //[RelayCommand] + //private void NavigateHome() => _navigationService.NavigateTo(); [RelayCommand] private void NavigateSettings() => _navigationService.NavigateTo(); @@ -53,8 +62,16 @@ private void ShowAbout() _dialogService.ShowError("About", "Typical: A Terminal.Gui v2 MVVM Demo"); } - public void Receive(NavigationChangedMessage message) + public void Receive(TestCompletedMessage message) + { + _navigationService.ShowModal(vm => vm.Initialize(message.Result)); + } + + public async void Receive(TestResetMessage message) { - CurrentPage = message.Value; + _navigationService.NavigateTo(vm => + { + vm.InitializeAsync().Wait(); + }); } } diff --git a/src/Typical.Core/ViewModels/ResultsViewModel.cs b/src/Typical.Core/ViewModels/ResultsViewModel.cs new file mode 100644 index 0000000..0b09071 --- /dev/null +++ b/src/Typical.Core/ViewModels/ResultsViewModel.cs @@ -0,0 +1,26 @@ +using System.Collections.ObjectModel; + +using CommunityToolkit.Mvvm.ComponentModel; + +using Typical.Core.Interfaces; +using Typical.Core.Statistics; + +namespace Typical.Core.ViewModels; + +public class ResultsViewModel : ObservableObject, IModalViewModel +{ + public bool Result => true; + + public ObservableCollection Snapshots { get; } = new(); + + public event EventHandler? RequestClose; + + public void Initialize(TestResult result) + { + Snapshots.Clear(); + foreach (var snap in result.Snapshots) + { + Snapshots.Add(snap); + } + } +} diff --git a/src/Typical.Core/ViewModels/SettingsViewModel.cs b/src/Typical.Core/ViewModels/SettingsViewModel.cs index 756f4a3..76f55d4 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,21 +20,23 @@ 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); + var message = new TestResetMessage(new QuoteMode(QuoteLength.Medium)); + _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 4fefc39..6c47e75 100644 --- a/src/Typical.Core/ViewModels/StatsViewModel.cs +++ b/src/Typical.Core/ViewModels/StatsViewModel.cs @@ -1,25 +1,33 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; + using Typical.Core.Events; using Typical.Core.Statistics; namespace Typical.Core.ViewModels; -public partial class StatsViewModel : ObservableObject, IRecipient +public partial class StatsViewModel : ObservableObject, IRecipient { + private readonly IMessenger _messenger; + [ObservableProperty] - public partial GameSnapshot Stats { get; set; } + public partial TestSnapshot Stats { get; set; } = TestSnapshot.Empty; - public StatsViewModel() + public StatsViewModel(IMessenger messenger) { - WeakReferenceMessenger.Default.Register( + _messenger = messenger; + _messenger.Register( this, (r, m) => r.Receive(m) ); } - public void Receive(GameStateUpdatedMessage message) + public void Receive(TestSessionUpdatedMessage message) { - Stats = message.State; + 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 66b83a0..a779a70 100644 --- a/src/Typical.Core/ViewModels/TypingViewModel.cs +++ b/src/Typical.Core/ViewModels/TypingViewModel.cs @@ -1,135 +1,139 @@ using System.Diagnostics.CodeAnalysis; - +using System.Timers; 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; using Typical.Core.Text; +using Timer = System.Timers.Timer; namespace Typical.Core.ViewModels; -public partial class TypingViewModel - : ObservableObject, - INavigatableView, - IRecipient +public partial class TypingViewModel : ObservableObject, INavigatableView { - private readonly GameEngine _engine; + private readonly TypingTest _Test; private readonly ITextProvider _textProvider; - private readonly INavigationService _navigationService; + private readonly IStatsRepository _statsRepository; private readonly ILogger _logger; + private readonly IMessenger _messenger; + private readonly Timer _refreshTimer; + private bool _isFinishing; [ObservableProperty] public required partial TextSample Target { get; set; } = TextSample.Empty; - // [ObservableProperty] - // private bool _isGameOver; - - [ObservableProperty] - public partial KeystrokeType[] DisplayStates { get; set; } = []; - [SetsRequiredMembers] public TypingViewModel( - GameEngine engine, + TypingTest Test, ITextProvider textProvider, + IStatsRepository statsRepository, INavigationService navigationService, - ILogger logger + ILogger logger, + IMessenger messenger ) { - _engine = engine; + _Test = Test; + _Test.OnTestFinished += async (s, result) => await HandleTestFinished(result); _textProvider = textProvider; - _navigationService = navigationService; + _statsRepository = statsRepository; _logger = logger; + _messenger = messenger; - WeakReferenceMessenger.Default.Register( - this, - (r, m) => r.Receive(m) - ); + _refreshTimer = new Timer(100); + _refreshTimer.AutoReset = true; + _refreshTimer.Elapsed += OnRefreshTimerElapsed; } - public bool IsGameOver => _engine.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(char c, bool isBackspace) + public async void ProcessInput(string c, bool isBackspace) { - if (_engine.IsOver) + if (_Test.IsOver) { await InitializeAsync(); return; } - bool accepted = _engine.ProcessKeyPress(c, isBackspace); - - var states = _engine.CharacterStates.ToArray(); + bool accepted = _Test.ProcessKeyPress(c, isBackspace); - if (!accepted && !isBackspace && c != '\0') - { - int pos = _engine.UserInput.Length; - if (pos < states.Length) - { - states[pos] = KeystrokeType.Incorrect; - } - } - - DisplayStates = states; UpdateState(); } - public void RefreshState() => UpdateState(); - /// /// Synchronizes the Engine state with ViewModel properties. /// This triggers PropertyChanged notifications for the View. /// private void UpdateState() { - var snapshot = _engine.CreateSnapshot(); - - WeakReferenceMessenger.Default.Send(new GameStateUpdatedMessage(snapshot)); - } - - public KeystrokeType GetStatus(int index) - { - return index < 0 || index >= _engine.CharacterStates.Count - ? KeystrokeType.Untyped - : _engine.CharacterStates[index]; + var snapshot = _Test.GetCurrentSnapshot(); + _messenger.Send(new TestSessionUpdatedMessage(snapshot)); } 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) + { + try + { + if (_Test.IsOver) + { + return; + } + + _Test.Stats.SampleSnapshot(); + UpdateState(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in refresh timer callback"); + } } 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); + _Test.LoadText(Target); UpdateState(); } - public async void Receive(GameResetMessage message) + public KeystrokeType GetStatus(int globalIdx) { - TextSample textSample = message.Settings switch + return _Test.GetStatus(globalIdx); + } + + private async Task HandleTestFinished(TestResult result) + { + if (_isFinishing) + return; + _isFinishing = true; + + try { - QuoteMode q => (await _textProvider.GetQuoteAsync(q.Length)), - _ => throw new InvalidOperationException( - $"Unsupported mode settings type: {message.Settings.Value?.GetType().Name ?? message.Settings.GetType().Name}" - ), - }; + _refreshTimer.Stop(); - await InitializeAsync(textSample); + await Task.Delay(100); + + await _statsRepository.SaveTestResultAsync(result); + _messenger.Send(new TestCompletedMessage(result)); + } + finally + { + _isFinishing = false; + } } } diff --git a/src/Typical.DataAccess/Add-MigrationFile.ps1 b/src/Typical.DataAccess/Add-MigrationFile.ps1 new file mode 100644 index 0000000..c2dfc1b --- /dev/null +++ b/src/Typical.DataAccess/Add-MigrationFile.ps1 @@ -0,0 +1,5 @@ +$dateString = Get-Date -Format 'yyyyMMddHHmm' + +$file = $dateString + '_description.sql' + +new-item "$PSScriptRoot/Migrations/$file" 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/Migrations/DatabaseMigrator.cs b/src/Typical.DataAccess/DatabaseMigrator.cs similarity index 78% rename from src/Typical.DataAccess/Migrations/DatabaseMigrator.cs rename to src/Typical.DataAccess/DatabaseMigrator.cs index 3d3c33a..485d73b 100644 --- a/src/Typical.DataAccess/Migrations/DatabaseMigrator.cs +++ b/src/Typical.DataAccess/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/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/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_AddTestTables.sql b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql new file mode 100644 index 0000000..46c7ae1 --- /dev/null +++ b/src/Typical.DataAccess/Migrations/202605230238_AddTestTables.sql @@ -0,0 +1,34 @@ + +CREATE TABLE Tests ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + CreatedAt INTEGER NOT NULL, + Wpm REAL, + RawWpm REAL, + Accuracy REAL, + DurationMs INTEGER, + QuoteId INTEGER NULL REFERENCES Quotes(Id), + CustomText TEXT NULL +); + +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; + +CREATE INDEX IX_Tests_QuoteId ON Tests (QuoteId); +CREATE INDEX IX_Tests_CreatedAt ON Tests (CreatedAt DESC); 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 92% rename from src/Typical.DataAccess/Migrations/Script_00200_SeedInitialQuotes.cs rename to src/Typical.DataAccess/Migrations/Script_202605230237_SeedInitialQuotes.cs index b402ac0..5593824 100644 --- a/src/Typical.DataAccess/Migrations/Script_00200_SeedInitialQuotes.cs +++ b/src/Typical.DataAccess/Migrations/Script_202605230237_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; @@ -13,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) { @@ -23,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/QuoteDto.cs b/src/Typical.DataAccess/QuoteDto.cs new file mode 100644 index 0000000..8f2738d --- /dev/null +++ b/src/Typical.DataAccess/QuoteDto.cs @@ -0,0 +1,43 @@ +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/ServiceExtensions.cs b/src/Typical.DataAccess/ServiceExtensions.cs index d420292..f049a67 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..5e1f8b3 --- /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 SaveTestResultAsync(TestResult result) + { + Debug.WriteLine("SimpleStatsRepository"); + + Debug.WriteLine(result.ToString()); + + return Task.CompletedTask; + } +} diff --git a/src/Typical.DataAccess/Sqlite/StatsRepository.cs b/src/Typical.DataAccess/Sqlite/StatsRepository.cs new file mode 100644 index 0000000..edf5179 --- /dev/null +++ b/src/Typical.DataAccess/Sqlite/StatsRepository.cs @@ -0,0 +1,145 @@ +using System.Reflection; +using System.Text; +using Dapper; +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 SaveTestResultAsync(TestResult result) + { + await using var connection = await GetConnectionAsync(); + await using var transaction = connection.BeginTransaction(); + + try + { + long testId = await InsertTestHeaderAsync(connection, transaction, result); + + await InsertTelemetryAsync(connection, transaction, testId, result); + + 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, QuoteId, CustomText) + VALUES (@CreatedAt, @Wpm, @RawWpm, @Accuracy, @DurationMs, @QuoteId, @CustomText); + SELECT last_insert_rowid(); + """; + + 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( + 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; + + foreach (var log in result.Telemetry) + { + pOffset.Value = log.Timestamp; + 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) + { + pTestId.Value = testId; + pOffset.Value = (long)snap.ElapsedTime.TotalMilliseconds; + + pWpm.Value = snap.WPM.Value; + pAcc.Value = snap.Accuracy.Value; + + await cmd.ExecuteNonQueryAsync(); + } + } + + private async Task GetConnectionAsync() + { + var connection = new SqliteConnection(options.Value.GetConnectionString()); + await connection.OpenAsync(); + return connection; + } +} diff --git a/src/Typical.DataAccess/Sqlite/TextRepository.cs b/src/Typical.DataAccess/Sqlite/TextRepository.cs index d8b03d2..ca8ee42 100644 --- a/src/Typical.DataAccess/Sqlite/TextRepository.cs +++ b/src/Typical.DataAccess/Sqlite/TextRepository.cs @@ -1,63 +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 { - 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 = + string sql = @" 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); - } - command.CommandText = - @" - SELECT Id, Text, Author, Tags, WordCount, CharCount - FROM Quotes - ORDER BY Id ASC LIMIT 1;"; + var dto = await connection.QueryFirstAsync(sql); - await using var wrapReader = await command.ExecuteReaderAsync(); - if (await wrapReader.ReadAsync()) - { - return MapReaderToQuote(wrapReader); - } - - throw new InvalidOperationException( - "No quotes found in the database. Ensure the migration and seeding scripts ran successfully." - ); + return dto.ToQuote(); } public async Task GetRandomQuoteAsync() @@ -65,47 +44,16 @@ 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() { 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 a6a791e..b98e89f 100644 --- a/src/Typical.DataAccess/Typical.DataAccess.csproj +++ b/src/Typical.DataAccess/Typical.DataAccess.csproj @@ -1,10 +1,13 @@  true + $(InterceptorsNamespaces);Dapper.AOT + true - + + @@ -15,7 +18,6 @@ - @@ -27,5 +29,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()}"; diff --git a/src/Typical.Tests/BindingTests.cs b/src/Typical.Tests/BindingTests.cs deleted file mode 100644 index 7b404e0..0000000 --- a/src/Typical.Tests/BindingTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Terminal.Gui.Input; -using Terminal.Gui.Views; -using Typical.Binding; - -namespace Typical.Tests; - -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.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..5579041 --- /dev/null +++ b/src/Typical.Tests/Core/Statistics/GameStatsTests.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Time.Testing; +using Typical.Core.Statistics; + +namespace Typical.Tests.Core.Statistics; + +public class TestStatsTests +{ + [Test] + public async Task CreateSnapshot_CalculatesAccurateWPM_BasedOnTime() + { + var fakeTime = new FakeTimeProvider(); + var stats = new TestSession(fakeTime); + + var builder = new TelemetryBuilder(stats, fakeTime).Type("hello "); + 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); + } + + [Test] + public async Task CreateSnapshot_CalculatesAccuracy_WithErrors() + { + var fakeTime = new FakeTimeProvider(); + var stats = new TestSession(fakeTime); + + // 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.GetCurrentSnapshot(); + + // 9 correct / 10 physical keystrokes = 90% + await Assert.That(snapshot.Accuracy.Value).IsEqualTo(90); + } + + [Test] + public async Task CreateSnapshot_HandlesZeroElapsed_PreventsDivideByZero() + { + var fakeTime = new FakeTimeProvider(); + var stats = new TestSession(fakeTime); + stats.Start(); + stats.Stop(); + 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/Statistics/KeystrokeCollectionTests.cs b/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs new file mode 100644 index 0000000..35b6535 --- /dev/null +++ b/src/Typical.Tests/Core/Statistics/KeystrokeCollectionTests.cs @@ -0,0 +1,76 @@ +using Typical.Core.Statistics; + +namespace Typical.Tests.Core.Statistics; + +public class KeystrokeCollectionTests +{ + [Test] + public async Task Add_CorrectIncrementsCorrectCount() + { + var kc = new KeystrokeCollection(); + // 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.TotalPhysical).IsEqualTo(1); + await Assert.That(kc.ErrorCount).IsEqualTo(0); + await Assert.That(kc.CorrectionCount).IsEqualTo(0); + } + + [Test] + public async Task Add_CalculatesRelativeOffsetMs() + { + var kc = new KeystrokeCollection(); + long startTicks = 10_000_000; // 1 second in ticks + long nextTicks = 15_000_000; // 1.5 seconds in ticks (500ms later) + + 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_StoresIndexCorrectly() + { + var kc = new KeystrokeCollection(); + 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, 1000, 0); + kc.Add("b", KeystrokeType.Incorrect, 2000, 1); + + kc.Clear(); + + // 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.TotalPhysical).IsEqualTo(0); + await Assert.That(kc.GetLog().Count).IsEqualTo(0); + } + + [Test] + public async Task GetLog_ReturnsDeepDataCorrectly() + { + var kc = new KeystrokeCollection(); + 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("á"); + await Assert.That(log[0].Type).IsEqualTo(KeystrokeType.Correct); + } +} diff --git a/src/Typical.Tests/Core/Statistics/TelemetryBuilder.cs b/src/Typical.Tests/Core/Statistics/TelemetryBuilder.cs new file mode 100644 index 0000000..c3f4962 --- /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 TestSession _stats; + private readonly FakeTimeProvider _time; + + public TelemetryBuilder(TestSession 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.Tests/Core/Statistics/TestFactory.cs b/src/Typical.Tests/Core/Statistics/TestFactory.cs new file mode 100644 index 0000000..31c4c3d --- /dev/null +++ b/src/Typical.Tests/Core/Statistics/TestFactory.cs @@ -0,0 +1,27 @@ +using Typical.Core.Text; + +namespace Typical.Core.Statistics; + +public static class TestFactory +{ + public static TestResult CreateResult( + double wpm = 60, + double rawWpm = 0, + double accuracy = 100, + TextSample? target = null, + List? telemetry = null, + List? snapshots = 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(), + 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 new file mode 100644 index 0000000..563dc60 --- /dev/null +++ b/src/Typical.Tests/Core/Statistics/TypingTestTests.cs @@ -0,0 +1,273 @@ +using System.Globalization; +using System.Text; +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; +using Typical.Core.Statistics; +using Typical.Core.Text; + +namespace Typical.Tests.Core.Statistics; + +public class TypingTestTests +{ + private readonly TimeProvider _timeProvider = new FakeTimeProvider(new DateTime(2025, 01, 01)); + private readonly MockTextProvider _mockTextProvider; + private readonly TestOptions _defaultOptions; + private readonly TestOptions _strictOptions; + private readonly Microsoft.Extensions.Logging.ILogger _logger; + private readonly TestSession _stats; + private const int BOGUS_SEED = 999_999_001; + private readonly Random _seed = new Random(BOGUS_SEED); + private readonly DefaultLogger _testLogger; + + public TypingTestTests() + { + _testLogger = TestContext.Current!.GetDefaultLogger(); + Bogus.Randomizer.Seed = _seed; + // This runs before each test, ensuring a clean state. + _mockTextProvider = new MockTextProvider(); + _defaultOptions = new TestOptions(); + _strictOptions = new TestOptions { ForbidIncorrectEntries = true }; + _logger = NullLogger.Instance; + _stats = new TestSession(_timeProvider); + } + + // --- StartNewTest Tests --- + + [Test] + public async Task StartNewTest_Always_LoadsTextFromProvider() + { + // Arrange + var expectedText = "This is a test."; + _mockTextProvider.SetText(expectedText); + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); + + // Act + sut.LoadText(await _mockTextProvider.GetWordsAsync()); + + // Assert + await Assert.That(sut.TargetText).IsEqualTo(expectedText); + } + + [Test] + public async Task StartNewTest_WhenTestWasAlreadyInProgress_ResetsState() + { + // Arrange + // 1. Initial Setup + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); + string firstText = "some text"; + + // 2. Load the first sut + sut.LoadText(new TextSample() { Text = firstText, Source = "test" }); + + // 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(sut.UserInput).IsEqualTo("so"); + await Assert.That(sut.IsRunning).IsTrue(); + + string newText = "new text"; + + // Act - Loading new text should completely reset the internal state + sut.LoadText(new TextSample() { Text = newText, Source = "test" }); + + // Assert + 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 --- + + [Test] + public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() + { + // Arrange + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); + + sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); + + sut.ProcessKeyPress("a", false); + sut.ProcessKeyPress("b", false); + await Assert.That(sut.UserInput).IsEqualTo("ab"); + + // Act + sut.ProcessKeyPress("\0", true); + + // Assert + await Assert.That(sut.UserInput).IsEqualTo("a"); + } + + [Test] + public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() + { + // Arrange + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); + sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); + await Assert.That(sut.UserInput).IsEmpty(); + + // Act + sut.ProcessKeyPress("\0", true); + + // Assert + await Assert.That(sut.UserInput).IsEmpty(); + } + + [Test] + public async Task ProcessKeyPress_WhenTestIsCompleted_SetsIsOverToTrue() + { + // Arrange + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); + sut.LoadText(new TextSample() { Text = "hi", Source = "test" }); + + // Act + sut.ProcessKeyPress("h", false); + sut.ProcessKeyPress("i", false); + + // Assert + await Assert.That(sut.UserInput).IsEqualTo("hi"); + await Assert.That(sut.IsOver).IsTrue(); + await Assert.That(sut.IsRunning).IsFalse(); + } + + // --- TestOptions: ForbidIncorrectEntries (Strict Mode) Tests --- + + [Test] + public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() + { + // Arrange + var sut = new TypingTest(_strictOptions, _logger, _timeProvider); // _testOptions.ForbidIncorrectEntries = true + sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); + + // Act + bool result = sut.ProcessKeyPress("a", false); + + // Assert + await Assert.That(result).IsTrue(); + await Assert.That(sut.UserInput).IsEqualTo("a"); + } + + [Test] + public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendCharacter() + { + // Arrange + var sut = new TypingTest(_strictOptions, _logger, _timeProvider); + sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); + + // Act + bool result = sut.ProcessKeyPress("x", false); + + // Assert + await Assert.That(result).IsTrue(); // Engine rejected the key + await Assert.That(sut.UserInput).IsEmpty(); + } + + [Test] + public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter() + { + // Arrange + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); // _testOptions.ForbidIncorrectEntries = false + sut.LoadText(new TextSample() { Text = "abc", Source = "test" }); + + // Act + bool result = sut.ProcessKeyPress("x", false); + + // Assert + await Assert.That(result).IsTrue(); // Engine accepted the mistake + await Assert.That(sut.UserInput).IsEqualTo("x"); + } + + [Test] + public async Task ProcessKeyPress_WithRandomText_MatchesState() + { + var lorem = new Bogus.DataSets.Lorem("ru") { Random = new Randomizer(BOGUS_SEED) }; + + var text = lorem.Sentence(); + + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); + 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] + public async Task ProcessKeyPress_WithEmoji_HandlesGraphemesCorrectly() + { + // Arrange: emoji with modifier (👍🏽) + var emojiText = "👍🏽"; + var sut = new TypingTest(_defaultOptions, _logger, _timeProvider); + sut.LoadText(new TextSample { Text = emojiText, Source = "test" }); + + // Act: process the emoji as a single grapheme + sut.ProcessKeyPress("👍🏽", false); + + // Assert: should count as exactly 1 correct keystroke + await Assert.That(sut.Stats.Keystrokes.Count).IsEqualTo(1); + await Assert.That(sut.Stats.Keystrokes[0].Value).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 sut = new TypingTest(_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 TypingTest(_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); + } +} diff --git a/src/Typical.Tests/Core/Text/TypingBufferTests.cs b/src/Typical.Tests/Core/Text/TypingBufferTests.cs new file mode 100644 index 0000000..06c6f11 --- /dev/null +++ b/src/Typical.Tests/Core/Text/TypingBufferTests.cs @@ -0,0 +1,78 @@ +using System.Threading.Tasks; +using TUnit; +using Typical.Core.Text; + +namespace Typical.Tests.Core.Text; + +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.Tests/Core/ViewModels/StatsViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs new file mode 100644 index 0000000..af635a3 --- /dev/null +++ b/src/Typical.Tests/Core/ViewModels/StatsViewModelTests.cs @@ -0,0 +1,37 @@ +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_TestStatsUpdatedMessage_UpdatesProperties() + { + var services = new ServiceCollection(); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + var messenger = provider.GetRequiredService(); + var sut = new StatsViewModel(messenger); + + var fakeSnapshot = new TestSnapshot( + WPM: (Wpm)65.8, + Accuracy: (Accuracy)98.5, + Metrics: new TestMetrics(10, 1, 2), + ElapsedTime: TimeSpan.FromSeconds(30) + ); + var msg = new TestSessionUpdatedMessage(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.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)); + } +} diff --git a/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs new file mode 100644 index 0000000..bc915ff --- /dev/null +++ b/src/Typical.Tests/Core/ViewModels/TypingViewModelTests.cs @@ -0,0 +1,70 @@ +using CommunityToolkit.Mvvm.Messaging; +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; +using Typical.Core.ViewModels; + +[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() + { + var mockTextProvider = new MockTextProvider(); + mockTextProvider.SetText("Hello world!"); + var engine = new TypingTest( + TestOptions.Default, + NullLogger.Instance, + TimeProvider.System + ); + + var vm = new TypingViewModel( + engine, + mockTextProvider, + mockStatsRepository.Instance(), + 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 TypingTest( + TestOptions.Default, + NullLogger.Instance, + TimeProvider.System + ); + var vm = new TypingViewModel( + engine, + mockTextProvider, + mockStatsRepository.Instance(), + 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"); + } +} diff --git a/src/Typical.Tests/Data/TextRepositoryTests.cs b/src/Typical.Tests/Data/TextRepositoryTests.cs new file mode 100644 index 0000000..6d60fca --- /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/GameEngineTests.cs b/src/Typical.Tests/GameEngineTests.cs deleted file mode 100644 index 71c6e19..0000000 --- a/src/Typical.Tests/GameEngineTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Typical.Core; -using Typical.Core.Events; -using Typical.Core.Statistics; -using Typical.Core.Text; - -namespace Typical.Tests; - -public class TypicalGameTests -{ - private readonly MockTextProvider _mockTextProvider; - private readonly GameOptions _defaultOptions; - private readonly GameOptions _strictOptions; - private readonly ILogger _logger; - private readonly GameStats _stats; - - public TypicalGameTests() - { - // This runs before each test, ensuring a clean state. - _mockTextProvider = new MockTextProvider(); - _defaultOptions = new GameOptions(); - _strictOptions = new GameOptions { ForbidIncorrectEntries = true }; - _logger = NullLogger.Instance; - _stats = new GameStats(); - } - - // --- StartNewGame Tests --- - - [Test] - public async Task StartNewGame_Always_LoadsTextFromProvider() - { - // Arrange - var expectedText = "This is a test."; - _mockTextProvider.SetText(expectedText); - var game = new GameEngine(_defaultOptions, _logger); - - // Act - game.LoadText(await _mockTextProvider.GetWordsAsync()); - - // Assert - await Assert.That(game.TargetText).IsEqualTo(expectedText); - } - - [Test] - public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() - { - // Arrange - // 1. Initial Setup - var game = new GameEngine(_defaultOptions, _logger); - string firstText = "some text"; - - // 2. Load the first game - game.LoadText(new TextSample() { Text = firstText, Source = "test" }); - - // 3. Simulate playing the game - game.ProcessKeyPress('s', false); // Correct first char - game.ProcessKeyPress('o', false); - - // Check that we actually have progress - await Assert.That(game.UserInput).IsEqualTo("so"); - await Assert.That(game.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" }); - - // 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(); - } - - // --- ProcessKeyPress Tests --- - - [Test] - public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() - { - // Arrange - var game = new GameEngine(_defaultOptions, _logger); - - game.LoadText(new TextSample() { Text = "abc", Source = "test" }); - - 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); - - // Assert - await Assert.That(game.UserInput).IsEqualTo("a"); - await Assert.That(game.CharacterStates[1]).IsEqualTo(KeystrokeType.Untyped); - } - - [Test] - public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() - { - // Arrange - var game = new GameEngine(_defaultOptions, _logger); - game.LoadText(new TextSample() { Text = "abc", Source = "test" }); - await Assert.That(game.UserInput).IsEmpty(); - - // Act - game.ProcessKeyPress('\0', true); - - // Assert - await Assert.That(game.UserInput).IsEmpty(); - } - - [Test] - public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() - { - // Arrange - var game = new GameEngine(_defaultOptions, _logger); - game.LoadText(new TextSample() { Text = "hi", Source = "test" }); - - // Act - game.ProcessKeyPress('h', false); - game.ProcessKeyPress('i', false); - - // Assert - await Assert.That(game.UserInput).IsEqualTo("hi"); - await Assert.That(game.IsOver).IsTrue(); - await Assert.That(game.IsRunning).IsFalse(); - } - - // --- GameOptions: ForbidIncorrectEntries (Strict Mode) Tests --- - - [Test] - public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() - { - // Arrange - var game = new GameEngine(_strictOptions, _logger); // _gameOptions.ForbidIncorrectEntries = true - game.LoadText(new TextSample() { Text = "abc", Source = "test" }); - - // Act - bool result = game.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); - } - - [Test] - public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendCharacter() - { - // Arrange - var game = new GameEngine(_strictOptions, _logger); - game.LoadText(new TextSample() { Text = "abc", Source = "test" }); - - // Act - bool result = game.ProcessKeyPress('x', false); - - // Assert - await Assert.That(result).IsFalse(); // Engine rejected the key - await Assert.That(game.UserInput).IsEmpty(); - await Assert.That(game.CharacterStates[0]).IsEqualTo(KeystrokeType.Untyped); - } - - [Test] - public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter() - { - // Arrange - var game = new GameEngine(_defaultOptions, _logger); // _gameOptions.ForbidIncorrectEntries = false - game.LoadText(new TextSample() { Text = "abc", Source = "test" }); - - // Act - 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); - } -} diff --git a/src/Typical.Tests/NavigationServiceTests.cs b/src/Typical.Tests/NavigationServiceTests.cs deleted file mode 100644 index fdf854d..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(GameEngine))] - -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 GameEngine(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 a281a28..0000000 --- a/src/Typical.Tests/StatsViewModelTests.cs +++ /dev/null @@ -1,36 +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( - WordsPerMinute: 65.8, - Accuracy: (Accuracy)98.5, - Chars: new CharacterStats(0, 0, 0, 0), - ElapsedTime: TimeSpan.FromSeconds(30), - IsRunning: true, - TargetText: "Test", - UserInput: "Test", - IsOver: true - ); - - var gameEvent = new GameStateUpdatedMessage(State: fakeState); - - messenger.Send(gameEvent); - - await Assert.That(sut.Stats.WordsPerMinute).IsEqualTo(65.8); - await Assert.That(sut.Stats.Accuracy).IsEqualTo((Accuracy)98.5); - } -} 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.Tests/UI/BindingTests.cs b/src/Typical.Tests/UI/BindingTests.cs new file mode 100644 index 0000000..5f0ec33 --- /dev/null +++ b/src/Typical.Tests/UI/BindingTests.cs @@ -0,0 +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"); +// } +// } diff --git a/src/Typical.Tests/UI/NavigationServiceTests.cs b/src/Typical.Tests/UI/NavigationServiceTests.cs new file mode 100644 index 0000000..936c3e6 --- /dev/null +++ b/src/Typical.Tests/UI/NavigationServiceTests.cs @@ -0,0 +1,130 @@ +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.Data; +using Typical.Core.Events; +using Typical.Core.Interfaces; +using Typical.Core.Text; +using Typical.Core.ViewModels; +using Typical.Services; +using Typical.UI.Views; + +[assembly: GenerateImposter(typeof(IDialogService))] +[assembly: GenerateImposter(typeof(ITextProvider))] + +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!; + + [Before(Test)] + public void Setup() + { + var services = new ServiceCollection(); + + _messenger = new StrongReferenceMessenger(); + services.AddSingleton(_messenger); + + services.AddSingleton(_dialogServiceMock.Instance()); + services.AddSingleton(_textProviderMock.Instance()); + services.AddSingleton(_statsRepoMock.Instance()); + + services.AddSingleton(sp => new TypingTest( + new TestOptions(), + NullLogger.Instance, + TimeProvider.System + )); + + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton>(NullLogger.Instance); + + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); + + services.AddSingleton(sp => new NavigationService( + sp, + null!, + sp.GetRequiredService(), + NullLogger.Instance + )); + + _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(SettingsViewModel).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 = Navigation.ViewLocator.GetView(_serviceProvider, viewModel); + + // Assert + await Assert.That(view).IsNotNull(); + await Assert.That(view).IsAssignableTo(); + } + } +} diff --git a/src/Typical/Binding/BindingContext.cs b/src/Typical/Binding/BindingContext.cs deleted file mode 100644 index 8b5ec41..0000000 --- a/src/Typical/Binding/BindingContext.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace Typical.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/Binding/BindingExtensions.cs b/src/Typical/Binding/BindingExtensions.cs deleted file mode 100644 index e8a46e2..0000000 --- a/src/Typical/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.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/Binding/DisposableAction.cs b/src/Typical/Binding/DisposableAction.cs deleted file mode 100644 index 6d5534a..0000000 --- a/src/Typical/Binding/DisposableAction.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Typical.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/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 172912d..737ae25 100644 --- a/src/Typical/Navigation/ViewLocator.cs +++ b/src/Typical/Navigation/ViewLocator.cs @@ -1,7 +1,8 @@ +using System.Reflection.Emit; using Microsoft.Extensions.DependencyInjection; using Terminal.Gui.ViewBase; using Typical.Core.ViewModels; -using Typical.Views; +using Typical.UI.Views; namespace Typical.Navigation; @@ -10,9 +11,9 @@ 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(), _ => throw new ArgumentException($"No view registered for {viewModel.GetType()}"), }; } diff --git a/src/Typical/Program.cs b/src/Typical/Program.cs index 09e89d4..679e5bf 100644 --- a/src/Typical/Program.cs +++ b/src/Typical/Program.cs @@ -1,15 +1,16 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using MinCh.Infrastructure; using Serilog; +using Stanza.TerminalGui; 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.Views; +using Typical.UI.Views; using Velopack; VelopackApp.Build().Run(); @@ -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 d15efbe..8430037 100644 --- a/src/Typical/Services/NavigationService.cs +++ b/src/Typical/Services/NavigationService.cs @@ -1,8 +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; @@ -14,16 +20,19 @@ public class NavigationService : ObservableObject, INavigationService private readonly IServiceProvider _services; private readonly IApplication _app; private readonly IMessenger _messenger; + private readonly ILogger _logger; public NavigationService( IServiceProvider services, IApplication app, - IMessenger? messenger = null + IMessenger messenger, + ILogger logger ) { _services = services; _app = app; - _messenger = messenger ?? WeakReferenceMessenger.Default; + _messenger = messenger; + _logger = logger; } private ObservableObject? _currentViewModel; @@ -46,29 +55,71 @@ 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 { var vm = _services.GetRequiredService(); - configure?.Invoke(vm); var view = ViewLocator.GetView(_services, vm); - if (view is IRunnable runnable) + if (typeof(view).) + { + + } + 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) { - _app.RequestStop(); - vm.RequestClose -= handler; - }; - vm.RequestClose += handler; - _app.Run(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); + } } - else + finally { - var host = new Dialog { Title = "Modal Host" }; - host.Add(view); - _app.Run(host); + vm.RequestClose -= handler; + + view.Dispose(); } return vm.Result; diff --git a/src/Typical/Services/ServiceExtensions.cs b/src/Typical/Services/ServiceExtensions.cs index c2586de..cbc5100 100644 --- a/src/Typical/Services/ServiceExtensions.cs +++ b/src/Typical/Services/ServiceExtensions.cs @@ -9,7 +9,7 @@ using Typical.Configuration; using Typical.Core.Interfaces; using Typical.Logging; -using Typical.Views; +using Typical.UI.Views; namespace Typical.Services; @@ -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,10 +53,11 @@ 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(); + builder.Services.AddTransient(); } } 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/Views/MainShell.cs b/src/Typical/UI/Views/MainShell.cs similarity index 81% rename from src/Typical/Views/MainShell.cs rename to src/Typical/UI/Views/MainShell.cs index 7fdb3d5..f17b9f6 100644 --- a/src/Typical/Views/MainShell.cs +++ b/src/Typical/UI/Views/MainShell.cs @@ -1,18 +1,18 @@ 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.Binding; using Typical.Core.ViewModels; using Typical.Navigation; -namespace Typical.Views; +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,15 +78,6 @@ 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.NavigateToGameViewCommand.Execute(null); - this.Activating += (s, e) => { if (e.Context?.Binding is MouseBinding { MouseEvent: { } mouse }) @@ -104,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) @@ -120,6 +108,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/src/Typical/UI/Views/ResultsView.cs b/src/Typical/UI/Views/ResultsView.cs new file mode 100644 index 0000000..fd7ef07 --- /dev/null +++ b/src/Typical/UI/Views/ResultsView.cs @@ -0,0 +1,54 @@ +using System.Drawing; + +using Stanza.TerminalGui; + +using Terminal.Gui.Input; +using Terminal.Gui.Views; + +using Typical.Core.ViewModels; + +namespace Typical.UI.Views; + +[StanzaView] +public partial class ResultsDialog : Dialog +{ + private readonly GraphView _graph; + + protected override bool OnAccepting(CommandEventArgs args) + { + if (base.OnAccepting(args)) + { + return true; + } + return false; + } + + public ResultsDialog(ResultsViewModel viewModel) + { + AddButton(new() { Text = "_Cancel" }); + 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(); + }; + } +} diff --git a/src/Typical/Views/SettingsView.cs b/src/Typical/UI/Views/SettingsView.cs similarity index 55% rename from src/Typical/Views/SettingsView.cs rename to src/Typical/UI/Views/SettingsView.cs index 9d38f70..90e681a 100644 --- a/src/Typical/Views/SettingsView.cs +++ b/src/Typical/UI/Views/SettingsView.cs @@ -1,16 +1,19 @@ +using Stanza.TerminalGui; + using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -using Typical.Binding; + using Typical.Core.ViewModels; -namespace Typical.Views; +namespace Typical.UI.Views; -public class SettingsView : BindableView +[StanzaView] +public partial class SettingsView : View { + [BindCommand(nameof(SettingsViewModel.QuoteModeCommand))] private readonly Button _btnQuoteMode; public SettingsView(SettingsViewModel viewModel) - : base(viewModel) { Width = Dim.Fill(); Height = Dim.Fill(); @@ -18,10 +21,7 @@ public SettingsView(SettingsViewModel viewModel) _btnQuoteMode = new Button { X = Pos.Center(), Text = "Quote" }; Add(_btnQuoteMode); - } - protected override void SetupBindings() - { - BindingContext.AddBinding(ViewModel.BindCommand(ViewModel.QuoteModeCommand, _btnQuoteMode)); + ViewModel = viewModel; } } diff --git a/src/Typical/UI/Views/StatsView.cs b/src/Typical/UI/Views/StatsView.cs new file mode 100644 index 0000000..53c8112 --- /dev/null +++ b/src/Typical/UI/Views/StatsView.cs @@ -0,0 +1,35 @@ +using Stanza.TerminalGui; + +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +using Typical.Core.ViewModels; + +namespace Typical.UI.Views; + +[StanzaView] +public partial class StatsView : View +{ + [BindText(nameof(StatsViewModel.StatsLabel))] + private readonly Label _statsLabel; + + public StatsView(StatsViewModel 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); + + ViewModel = viewModel; + } + + partial void OnApplyBindings(BindingContext context) + { + + } + +} diff --git a/src/Typical/Views/TypingArea.cs b/src/Typical/UI/Views/TypingArea.cs similarity index 69% rename from src/Typical/Views/TypingArea.cs rename to src/Typical/UI/Views/TypingArea.cs index 062ded4..35c7df0 100644 --- a/src/Typical/Views/TypingArea.cs +++ b/src/Typical/UI/Views/TypingArea.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using Terminal.Gui.Configuration; using Terminal.Gui.Text; @@ -6,7 +7,7 @@ using Typical.Core.ViewModels; using Attribute = Terminal.Gui.Drawing.Attribute; -namespace Typical.Views; +namespace Typical.UI.Views; public class TypingArea : View { @@ -26,9 +27,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() @@ -54,29 +52,44 @@ 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()) { - if (globalIdx >= _viewModel.DisplayStates.Length) - break; + string grapheme = enumerator.GetTextElement(); - var state = _viewModel.DisplayStates[globalIdx]; + var state = _viewModel.GetStatus(globalIdx); SetAttribute(GetAttributeForState(state)); - AddRune(x + xOffset, y + yOffset, (Rune)line[x]); + Rune r = grapheme.EnumerateRunes().First(); + + AddRune(xPos, yPos, r); + + xPos += r.GetColumns(); globalIdx++; } + yPos++; } return true; } + 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 { diff --git a/src/Typical/Views/TypingView.cs b/src/Typical/UI/Views/TypingView.cs similarity index 53% rename from src/Typical/Views/TypingView.cs rename to src/Typical/UI/Views/TypingView.cs index c7ffaa8..6c02352 100644 --- a/src/Typical/Views/TypingView.cs +++ b/src/Typical/UI/Views/TypingView.cs @@ -1,28 +1,31 @@ -using System.ComponentModel; using System.Text; -using System.Timers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Stanza.TerminalGui; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; using Typical.Core.ViewModels; -namespace Typical.Views; +namespace Typical.UI.Views; -public class TypingView : BindableView +[StanzaView] +public partial class TypingView : View { private readonly TypingArea _typingArea; private readonly Label _sourceLabel; - private readonly System.Timers.Timer _refreshTimer = new(100); + private readonly ILogger _logger; - public TypingView(TypingViewModel viewModel) - : base(viewModel) + public TypingView(TypingViewModel viewModel, ILogger? logger = null) { CanFocus = true; X = Pos.Center(); Y = Pos.Center(); Width = Dim.Fill(); Height = Dim.Fill(); - + ViewModel = viewModel; + _logger = logger ?? NullLogger.Instance; + _bindingContext = new BindingContext(); _typingArea = new TypingArea(viewModel) { X = Pos.Center(), @@ -33,13 +36,20 @@ public TypingView(TypingViewModel viewModel) _sourceLabel = new Label(); Add(_typingArea); - _refreshTimer.AutoReset = true; - _refreshTimer.Elapsed += OnRefreshTimerElapsed; + this.Bind( + ViewModel, + vm => vm.Target, + target => + { + _typingArea.Refresh(); + _sourceLabel.Text = target?.Source ?? string.Empty; + } + ) + .AddTo(_bindingContext); Initialized += (s, e) => { _ = InitializeViewAsync(); - _refreshTimer.Start(); }; this.Activating += (s, e) => { @@ -48,16 +58,6 @@ public TypingView(TypingViewModel viewModel) }; } - private void OnRefreshTimerElapsed(object? sender, ElapsedEventArgs e) - { - if (ViewModel.IsGameOver) - { - return; - } - - App?.Invoke(() => ViewModel.RefreshState()); - } - protected override void OnSubViewsLaidOut(LayoutEventArgs args) { base.OnSubViewsLaidOut(args); @@ -75,16 +75,18 @@ 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) { - System.Diagnostics.Debug.WriteLine($"Input Error: {ex.Message}"); + _logger.LogError(ex, $"Input Error: {ex.Message}"); } return true; @@ -93,39 +95,16 @@ 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 override void Dispose(bool disposing) - { - if (disposing) - { - _refreshTimer.Stop(); - _refreshTimer.Dispose(); - } - - base.Dispose(disposing); - } - private async Task InitializeViewAsync() { try { - await ViewModel.InitializeAsync(); + await (ViewModel?.InitializeAsync() ?? Task.CompletedTask); _typingArea.Refresh(); } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Init Error: {ex.Message}"); + _logger.LogError(ex, $"Init Error: {ex.Message}"); } } } diff --git a/src/Typical/Views/BindableView.cs b/src/Typical/Views/BindableView.cs deleted file mode 100644 index 74f77da..0000000 --- a/src/Typical/Views/BindableView.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Runtime.CompilerServices; -using CommunityToolkit.Mvvm.ComponentModel; -using Terminal.Gui.ViewBase; -using Typical.Binding; -using Typical.Core.Interfaces; - -namespace Typical.Views; - -/// -/// 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 ?? throw new ArgumentNullException(nameof(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/Views/HomeView.cs b/src/Typical/Views/HomeView.cs deleted file mode 100644 index 6b0d0e9..0000000 --- a/src/Typical/Views/HomeView.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; -using Typical.Binding; -using Typical.Core.ViewModels; - -namespace Typical.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/Views/StatsView.cs b/src/Typical/Views/StatsView.cs deleted file mode 100644 index ff795fd..0000000 --- a/src/Typical/Views/StatsView.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Terminal.Gui.Drawing; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; -using Typical.Core.ViewModels; - -namespace Typical.Views; - -public class StatsView : BindableView -{ - private readonly Label _statsLabel; - - public StatsView(StatsViewModel viewModel) - : base(viewModel) - { - Title = nameof(StatsView); - BorderStyle = LineStyle.None; - Height = 3; - Width = Dim.Fill(); - _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.WordsPerMinute)} | Acc: {stats.Accuracy.ToString()}"; - SetNeedsDraw(); - } - ); - } -} diff --git a/todo.md b/todo.md index 24754ae..69ff7b9 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 Test Statistics + +- **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 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. + +### 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 TestStats test file.* + +### Task 2.1: WPM and Accuracy Math Tests + +- **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 `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`: + - **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_TestStatsUpdatedMessage_UpdatesProperties`: + - **Setup:** Instantiate `StatsViewModel`. + - **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 + +- **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 `TestStatsUpdatedMessage`. + 3. `Receive_TestResetMessage_ReloadsText_BasedOnSettings`: + - **Action:** Call `Receive(new TestResetMessage(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/TypingTestTests.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 `TestStats.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).