Arithmetic Trainer Round 2 - Timed and Fixed Length Tests
A previous post introduces the Arithmetic Trainer project and showcases its development from zero to an initial prototype. This post revisits the project and extends it with additional training activities.
Arithmetic Trainer currently offers a single training activity: Practice, which lets the user solve problems at their own pace, as many as they wish until they choose to stop:
Starting Practice on Multiplication (*) On Range 2 to 99. Press q to stop.
3 * 5 = ?
15
Correct
22 * 4 = ?
88
Correct
7 * 7 = ?
q
This post showcases two new training activities:
- Fixed Time Test - for solving as many problems as possible within a specified time limit.
- Fixed Length Test - for completing a set number of problems in the shortest time possible.
The project’s source code is available on GitHub here.
- The starting state of the project: here.
- The final state of the project: here.
- The pull request with all the changes: here.
Training Mode
Currently, the app prompts for the practice mode before starting a practice. Practice mrode corresponds to which problem generator to use:
case startPractice:
string practiceMode = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Pick practice mode: ")
.AddChoices(
additionPractice,
subtractionPractice,
multiplicationPractice,
divisionPractice,
mixedPractice,
cancel));
switch (practiceMode)
{
case additionPractice:
DoPractice(new AdditionProblemGenerator());
break;
case subtractionPractice:
DoPractice(new SubtractionProblemGenerator());
break;
case multiplicationPractice:
DoPractice(new MultiplicationProblemGenerator());
break;
case divisionPractice:
DoPractice(new DivisionProblemGenerator());
break;
case mixedPractice:
DoPractice(new MixedProblemGenerator(
new AdditionProblemGenerator(),
new SubtractionProblemGenerator(),
new MultiplicationProblemGenerator(),
new DivisionProblemGenerator()));
break;
case cancel:
continue;
}
break;
The practice mode is independent of the training activity, i.e. the choice will be identical for the two test activities. Therefore, practice mode can be renamed to training mode. And the block of code to select training mode should be made reusable.
The first commit implements the changes for training mode reusability in three parts:
The training mode itself:
public record TrainingMode(string Label, ProblemGenerator ProblemGenerator);
The training mode catalogue, which represents a storage for all available training modes:
public sealed class TrainingModeCatalogue
{
private readonly IReadOnlyList<TrainingMode> _items =
[
new("Mixed (+-/*) On Range 2 to 99", new MixedProblemGenerator(
new AdditionProblemGenerator(),
new SubtractionProblemGenerator(),
new MultiplicationProblemGenerator(),
new DivisionProblemGenerator())),
new("Addition (+) On Range 2 to 99", new AdditionProblemGenerator()),
new("Subtraction (-) On Range 2 to 99", new SubtractionProblemGenerator()),
new("Division (/) On Range 2 to 99", new DivisionProblemGenerator()),
new("Multiplication (*) On Range 2 to 99", new MultiplicationProblemGenerator()),
];
public List<string> GetLabels() => _items.Select(m => m.Label).ToList();
public TrainingMode? GetByLabel(string label) => _items.FirstOrDefault(m => m.Label == label);
}
The training mode selection:
List<string> labels = trainingModeCatalogue.GetLabels();
string selection = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Pick Training Mode: ")
.AddChoices(labels)
.AddChoices(cancel)
);
return selection == cancel ? null : trainingModeCatalogue.GetByLabel(selection);
The training mode label holds what used to be problem generator description. The square brackets are removed because Spectre.Console
treats them as special characters leading to runtime errors.
As a result, the logic to select training mode is reduced to 2 lines of code, making it ready to be reused by new training activities.
TrainingMode? trainingMode = ConfigureTrainingMode();
if (trainingMode is null) break;
DoPractice(trainingMode);
Fixed Time Test
The first idea in implementing Fixed Time Test may be to have a live countdown timer combined with a field to input responses. However, this requires managing input (user responses) and output (live timer) simultaneously, which is too much interactivity for a simple console app.
An easy-to-implement alternative is to have a timer run in the background and display the remaining time after every user response. This way the training remains in the form of question-response-outcome loop. The last response is guaranteed to be overtime and not counted.
The second commit implements it:
void DoFixedTimeTest(TrainingMode trainingMode, TimeLimit timeLimit)
{
AnsiConsole.WriteLine($"Starting {timeLimit.Label} test on {trainingMode.Label}.");
List<Attempt> attempts = [];
Stopwatch stopwatch = Stopwatch.StartNew();
foreach (Problem problem in trainingMode.ProblemGenerator.Generate())
{
AnsiConsole.WriteLine(problem.Question);
string response = Console.ReadLine() ?? "";
Attempt attempt = new(problem, response);
long timeRemainingInSeconds = timeLimit.ValueInSeconds - stopwatch.ElapsedMilliseconds / 1000;
if (timeRemainingInSeconds > 0)
{
AnsiConsole.WriteLine($"{attempt.Outcome}. Time remaining: {timeRemainingInSeconds} seconds");
attempts.Add(attempt);
}
else
{
AnsiConsole.WriteLine("Time's up");
int correctCount = attempts.Count(attempt => attempt.IsCorrect);
int incorrectCount = attempts.Count - correctCount;
AnsiConsole.WriteLine($"Correct count: {correctCount} Incorrect count: {incorrectCount}");
break;
}
}
history.AddRange(attempts);
}
One problem with this activity is that it requires lots of configuration (multiple selections and key presses) while the training itself lasts a limited amount of time. The third commit fixes this problem by introducing "Try Again" prompt, which relaunches the test with the previous configuration:
do
{
DoFixedTimeTest(trainingMode, timeLimit);
} while (AnsiConsole.Prompt(new ConfirmationPrompt("Try Again?")));
Here is how it looks:
Fixed Length Test
The Fixed Length Test implementation proceeds identically to that of Fixed Time Test:
- Step 1, introduce
LengthLimit
,LengthLimitCatalogue
, andConfigureLengthLimit
- Step 2, implement the
DoFixedLengthTest
method
The fourth commit implements it:
void DoFixedLengthTest(TrainingMode trainingMode, LengthLimit lengthLimit)
{
int incorrectResponsePenaltyInSeconds = 10;
AnsiConsole.WriteLine($"Starting {lengthLimit.Label} test on {trainingMode.Label}.");
List<Attempt> attempts = [];
Stopwatch stopwatch = Stopwatch.StartNew();
for (int i = 1; i <= lengthLimit.Value; i++)
{
Problem problem = trainingMode.ProblemGenerator.Next();
AnsiConsole.WriteLine(problem.Question);
string response = Console.ReadLine() ?? "";
Attempt attempt = new(problem, response);
AnsiConsole.WriteLine($"{attempt.Outcome}. Problems remaining: {lengthLimit.Value-i}");
attempts.Add(attempt);
}
TimeSpan elapsedTime = stopwatch.Elapsed;
int correctCount = attempts.Count(attempt => attempt.IsCorrect);
int incorrectCount = attempts.Count - correctCount;
TimeSpan penaltyTime = TimeSpan.FromSeconds(incorrectCount * incorrectResponsePenaltyInSeconds);
TimeSpan finishTime = elapsedTime + penaltyTime;
AnsiConsole.WriteLine($"Finish Time: {Format(finishTime)} (including {Format(penaltyTime)} penalty time)");
history.AddRange(attempts);
string Format(TimeSpan timeSpan)
{
return timeSpan >= TimeSpan.FromHours(1) ? "Over 1 Hour" : timeSpan.ToString(@"mm\:ss");
}
}
Here is how it works:
Starting 10 Problems test on Addition (+) On Range 2 to 99.
36 + 13 = ?
49
Correct. Problems remaining: 9
...
44 + 42 = ?
85
Incorrect. The answer is 86. Problems remaining: 0
Finish Time: 00:26 (including 00:10 penalty time)
Try Again? [y/n] (y): n
Each incorrect response also incurs a 10 second penalty as a motivator to avoid skipping difficult problems; while unreasonably long finish times are shown as "Over 1 Hour"
Clean up
Now that all the new functionality has been successfully implemented, it is time to pause and reflect. How can the project be better organised to prepare for the next round of extensions?
Some functionality has already been abstracted. However, this process can be taken further, as demonstrated in the fifth commit. This commit extracts each method in the Program.cs
file into its own file, representing them as separate classes.
This leads to two benefits.
The first is a more clear dependency structure between methods:
History history = new();
DoFixedTimeTest doFixedTimeTest = new(history);
DoFixedLengthTest doFixedLengthTest = new(history);
DoPractice doPractice = new(history);
ShowHistory showHistory = new(history);
TrainingModeCatalogue trainingModeCatalogue = new();
TimeLimitCatalogue timeLimitCatalogue = new();
LengthLimitCatalogue lengthLimitCatalogue = new();
ConfigureTrainingMode configureTrainingMode = new(trainingModeCatalogue);
ConfigureTimeLimit configureTimeLimit = new(timeLimitCatalogue);
ConfigureLengthLimit configureLengthLimit = new(lengthLimitCatalogue);
The second is an overview of all available methods within IDE:
Conclusion
This post has revisited the Arithmetic Trainer project and shown its growth from an initial prototype with minimal functionality into one supporting multiple configurable training activities.
Potential next steps include revisiting and extending problem generators, or improving history and training statistics, or customising the UI, or adding persistence.