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).