From cdf4c58c28091019d3a74e8542283b5c39d71a65 Mon Sep 17 00:00:00 2001 From: Vaclav Vracovsky Date: Thu, 2 Apr 2026 07:28:13 +0200 Subject: [PATCH 1/5] Make program work on Ubuntu In future, I understand that it would be maybe necessary to develop on Windows, but for now, I didn't want to switch from Ubuntu to Windows only for purposes of a few hours task, so I decided to make the program work on Ubuntu. Hopefully it's not a big deal, I believe that the .NET version doesn't matter when the purpose of the task is to show programming skills. --- .gitignore | 3 +++ hoteltime.sln | 29 ++++++++++++++++++++ refactoring/App.config | 6 ----- refactoring/Fujtajbl.csproj | 53 +++++-------------------------------- 4 files changed, 38 insertions(+), 53 deletions(-) create mode 100644 .gitignore create mode 100644 hoteltime.sln delete mode 100644 refactoring/App.config diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9192ecc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +.vscode/ diff --git a/hoteltime.sln b/hoteltime.sln new file mode 100644 index 0000000..43de482 --- /dev/null +++ b/hoteltime.sln @@ -0,0 +1,29 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "refactoring", "refactoring", "{97EF2187-4C24-D818-2C49-4D787C71BD11}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fujtajbl", "refactoring\Fujtajbl.csproj", "{4052209C-18FC-6683-56E3-5E64701FD4EF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {4052209C-18FC-6683-56E3-5E64701FD4EF} = {97EF2187-4C24-D818-2C49-4D787C71BD11} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {3F766DA4-2526-48DE-BFD5-FF07C7F7062E} + EndGlobalSection +EndGlobal diff --git a/refactoring/App.config b/refactoring/App.config deleted file mode 100644 index 193aecc..0000000 --- a/refactoring/App.config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/refactoring/Fujtajbl.csproj b/refactoring/Fujtajbl.csproj index e5eea9d..e707986 100644 --- a/refactoring/Fujtajbl.csproj +++ b/refactoring/Fujtajbl.csproj @@ -1,53 +1,12 @@ - - - + + - Debug - AnyCPU - {0A8FA90C-3006-4D6E-B095-700D580E53CA} Exe + net10.0 Fujtajbl Fujtajbl - v4.8 - 512 - true - true + disable + false - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - - - + \ No newline at end of file From 9bd8f630e0cffaa3def54148ae9363ba0267ec40 Mon Sep 17 00:00:00 2001 From: Vaclav Vracovsky Date: Thu, 2 Apr 2026 11:38:59 +0200 Subject: [PATCH 2/5] Refactoring the calculator - using functional programming techniques - improved user experience with better messages, input handling and flow - possibility to configure decimal places via CLI args - possibility to easily add new operations in future --- .csharpierrc.json | 6 + Makefile | 10 ++ README.md | 51 ++++++++ hoteltime.sln | 2 +- refactoring/CliArgs.cs | 24 ++++ refactoring/ConsoleWriter.cs | 76 ++++++++++++ ...ajbl.csproj => HotelTimeCalculator.csproj} | 8 +- refactoring/Input.cs | 93 ++++++++++++++ refactoring/Makefile | 10 ++ refactoring/Messages.cs | 72 +++++++++++ refactoring/Operation.cs | 10 ++ refactoring/Operations.cs | 24 ++++ refactoring/Program.cs | 113 +++++++----------- refactoring/Properties/AssemblyInfo.cs | 6 +- refactoring/Result.cs | 11 ++ 15 files changed, 434 insertions(+), 82 deletions(-) create mode 100644 .csharpierrc.json create mode 100644 Makefile create mode 100644 refactoring/CliArgs.cs create mode 100644 refactoring/ConsoleWriter.cs rename refactoring/{Fujtajbl.csproj => HotelTimeCalculator.csproj} (68%) create mode 100644 refactoring/Input.cs create mode 100644 refactoring/Makefile create mode 100644 refactoring/Messages.cs create mode 100644 refactoring/Operation.cs create mode 100644 refactoring/Operations.cs create mode 100644 refactoring/Result.cs diff --git a/.csharpierrc.json b/.csharpierrc.json new file mode 100644 index 0000000..f658fa2 --- /dev/null +++ b/.csharpierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "useTabs": false, + "tabWidth": 4, + "endOfLine": "lf" +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e8ca041 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: build run format + +build: + $(MAKE) -C refactoring build + +run: + $(MAKE) -C refactoring run + +format: + $(MAKE) -C refactoring format diff --git a/README.md b/README.md index 4ba8d3e..5258a1e 100644 --- a/README.md +++ b/README.md @@ -1 +1,52 @@ Home assignment for https://www.hoteltime.com/ + +# HotelTime Calculator + +Interactive console calculator supporting addition, subtraction, multiplication, and division of decimal numbers. + +## Prerequisites + +- [.NET SDK 10.0+](https://dotnet.microsoft.com/download) +- CSharpier (code formatter): + ```bash + dotnet tool install --global csharpier + export PATH="$PATH:$HOME/.dotnet/tools" # add to ~/.bashrc or ~/.zshrc + ``` + +## Getting started + +```bash +cd refactoring +dotnet run +``` + +Or using `make` from the project root: + +```bash +make run # run the app +make build # build the solution +make format # format all C# files with CSharpier +``` + +## CLI options + +| Option | Description | Default | +|---|---|---| +| `--decimalPlaces ` | Number of decimal places in the result | `2` | + +### Examples + +```bash +dotnet run # result rounded to 2 decimal places +dotnet run -- --decimalPlaces 0 # result as a whole number +dotnet run -- --decimalPlaces 10 # result with 10 decimal places +``` + +## Usage + +1. Enter number A (decimal number, use `.` as separator) +2. Enter number B +3. Select an operation by typing `1`–`4` +4. When asked whether to run a new calculation: + - `a`, `A`, `y`, `Y`, `ano`, `yes` or **Enter** (default: yes) to continue + - `n`, `N`, `ne`, `no` to exit diff --git a/hoteltime.sln b/hoteltime.sln index 43de482..f0a57f4 100644 --- a/hoteltime.sln +++ b/hoteltime.sln @@ -4,7 +4,7 @@ VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "refactoring", "refactoring", "{97EF2187-4C24-D818-2C49-4D787C71BD11}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fujtajbl", "refactoring\Fujtajbl.csproj", "{4052209C-18FC-6683-56E3-5E64701FD4EF}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotelTimeCalculator", "refactoring\HotelTimeCalculator.csproj", "{4052209C-18FC-6683-56E3-5E64701FD4EF}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/refactoring/CliArgs.cs b/refactoring/CliArgs.cs new file mode 100644 index 0000000..4062fb6 --- /dev/null +++ b/refactoring/CliArgs.cs @@ -0,0 +1,24 @@ +namespace HotelTimeCalculator +{ + // Parses command-line arguments. + + static class CliArgs + { + public record ArgDef(string Name, T Default); + + public static readonly ArgDef DecimalPlaces = new("--decimalPlaces", 2); + + public record Args(int DecimalPlaces); + + public static Args Parse(string[] args) => + new(DecimalPlaces: ParseInt(args, DecimalPlaces)); + + private static int ParseInt(string[] args, ArgDef def) + { + for (var i = 0; i < args.Length - 1; i++) + if (args[i] == def.Name && int.TryParse(args[i + 1], out var n) && n >= 0) + return n; + return def.Default; + } + } +} diff --git a/refactoring/ConsoleWriter.cs b/refactoring/ConsoleWriter.cs new file mode 100644 index 0000000..a829d04 --- /dev/null +++ b/refactoring/ConsoleWriter.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; + +namespace HotelTimeCalculator +{ + // Centralized colored output. All color decisions live here. + // TextWriter is injected - no direct Console dependency. + + class ConsoleWriter(TextWriter output) + { + private readonly TextWriter _out = output; + + public void Error(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + _out.WriteLine($" {message}"); + Console.ResetColor(); + } + + public void Success(string message) + { + Console.ForegroundColor = ConsoleColor.Green; + _out.WriteLine(message); + Console.ResetColor(); + } + + public void Hint(string message) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + _out.Write(message); + Console.ResetColor(); + } + + public void Info(string message) + { + Console.ForegroundColor = ConsoleColor.Cyan; + _out.WriteLine(message); + Console.ResetColor(); + } + + public void Label(string message) + { + Console.ForegroundColor = ConsoleColor.DarkCyan; + _out.WriteLine(message); + Console.ResetColor(); + } + + public void Heading(string message) + { + Console.ForegroundColor = ConsoleColor.DarkYellow; + _out.WriteLine(); + _out.WriteLine($"\x1b[1m{message}\x1b[22m"); + _out.WriteLine(new string('-', message.Length + 4)); + Console.ResetColor(); + } + + public void PrintStartupInfo(int defaultDecimalPlaces, int currentDecimalPlaces) + { + var m = Messages.Current; + Heading(m.StartupTitle); + Info(m.StartupExitOptions); + Label(m.StartupAvailableOptions); + Hint($" {CliArgs.DecimalPlaces.Name} "); + _out.WriteLine( + $" {m.StartupDecimalPlaces} ({m.StartupDefault}: {defaultDecimalPlaces}, {m.StartupCurrent}: {currentDecimalPlaces})" + ); + _out.WriteLine(); + } + + public void Write(string message) => _out.Write(message); + + public void WriteLine(string message) => _out.WriteLine(message); + + public void WriteLine() => _out.WriteLine(); + } +} diff --git a/refactoring/Fujtajbl.csproj b/refactoring/HotelTimeCalculator.csproj similarity index 68% rename from refactoring/Fujtajbl.csproj rename to refactoring/HotelTimeCalculator.csproj index e707986..444fdd4 100644 --- a/refactoring/Fujtajbl.csproj +++ b/refactoring/HotelTimeCalculator.csproj @@ -1,12 +1,10 @@  - Exe net10.0 - Fujtajbl - Fujtajbl + HotelTimeCalculator + HotelTimeCalculator disable false - - \ No newline at end of file + diff --git a/refactoring/Input.cs b/refactoring/Input.cs new file mode 100644 index 0000000..c70670c --- /dev/null +++ b/refactoring/Input.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace HotelTimeCalculator +{ + // All I/O logic and defensive parsing. + // TextReader + ConsoleWriter are injected - no direct Console dependency. + + class Input(TextReader input, ConsoleWriter output) + { + private readonly TextReader _in = input; + private readonly ConsoleWriter _out = output; + + public static Result ParseDecimal(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return Result.Err(Messages.Current.ErrorEmptyInput); + + return decimal.TryParse( + raw, + System.Globalization.NumberStyles.Number, + System.Globalization.CultureInfo.InvariantCulture, + out var value + ) + ? Result.Ok(value) + : Result.Err($"'{raw}' {Messages.Current.ErrorNotNumber}"); + } + + public decimal ReadDecimal(string prompt) + { + while (true) + { + _out.Write(prompt); + var result = ParseDecimal(_in.ReadLine()); + if (result.IsOk) + return result.Value; + _out.Error( + $"{Messages.Current.ErrorPrefix} {result.Error} {Messages.Current.ErrorRetry}" + ); + } + } + + public Operation SelectOperation(IReadOnlyList operations) + { + while (true) + { + _out.WriteLine(); + _out.WriteLine(Messages.Current.PromptSelectOperation); + for (var i = 0; i < operations.Count; i++) + _out.WriteLine($" {operations[i].Key}: {operations[i].Label}"); + + _out.Write(Messages.Current.PromptYourChoice); + var key = _in.ReadLine()?.Trim(); + + for (var i = 0; i < operations.Count; i++) + { + if (operations[i].Key == key) + return operations[i]; + } + + _out.Error( + $"{Messages.Current.ErrorPrefix} '{key}' {Messages.Current.ErrorUnknownOperation}" + ); + } + } + + public bool ReadYesNo(string prompt, bool defaultValue = true) + { + var hint = defaultValue + ? Messages.Current.PromptYesNoDefaultYes + : Messages.Current.PromptYesNoDefaultNo; + + while (true) + { + _out.Write($"{prompt} "); + _out.Hint(hint); + var answer = _in.ReadLine()?.Trim().ToLowerInvariant(); + + if (string.IsNullOrEmpty(answer)) + return defaultValue; + + if (answer == "a" || answer == "ano" || answer == "y" || answer == "yes") + return true; + + if (answer == "n" || answer == "ne" || answer == "no") + return false; + + _out.Error($"{Messages.Current.ErrorPrefix} {Messages.Current.ErrorInvalidYesNo}"); + } + } + } +} diff --git a/refactoring/Makefile b/refactoring/Makefile new file mode 100644 index 0000000..50d651d --- /dev/null +++ b/refactoring/Makefile @@ -0,0 +1,10 @@ +.PHONY: build run format + +build: + dotnet build ../hoteltime.sln + +run: + dotnet run --project HotelTimeCalculator.csproj + +format: + csharpier format . diff --git a/refactoring/Messages.cs b/refactoring/Messages.cs new file mode 100644 index 0000000..9c2fb88 --- /dev/null +++ b/refactoring/Messages.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; + +namespace HotelTimeCalculator +{ + // Locale-aware message definitions. Adding a new key = adding a record field + // - compiler forces all locales to provide it. + + record Locale( + string PromptNumberA, + string PromptNumberB, + string PromptSelectOperation, + string PromptYourChoice, + string PromptNewCalculation, + string PromptYesNoDefaultYes, + string PromptYesNoDefaultNo, + string ErrorInvalidYesNo, + string ErrorEmptyInput, + string ErrorNotNumber, + string ErrorUnknownOperation, + string ErrorDivisionByZero, + string ErrorRetry, + string ResultPrefix, + string OperationPrefix, + string ErrorPrefix, + string StartupTitle, + string StartupExitOptions, + string StartupAvailableOptions, + string StartupDecimalPlaces, + string StartupDefault, + string StartupCurrent, + string PromptSameInputs + ); + + static class Messages + { + private const string DefaultLocale = "cs-CZ"; + + private static readonly IReadOnlyDictionary Locales = new Dictionary< + string, + Locale + > + { + [DefaultLocale] = new( + PromptNumberA: "Zadejte číslo A: ", + PromptNumberB: "Zadejte číslo B: ", + PromptSelectOperation: "Vyberte operaci:", + PromptYourChoice: "Vaše volba: ", + PromptNewCalculation: "Chcete spustit novy výpočet?", + PromptYesNoDefaultYes: "[A/n]: ", + PromptYesNoDefaultNo: "[a/N]: ", + ErrorInvalidYesNo: "Zadejte a (ano) nebo n (ne).", + ErrorEmptyInput: "Vstup je prázdný.", + ErrorNotNumber: "Není platné číslo.", + ErrorUnknownOperation: "Neznámá operace. Zkuste to znovu.", + ErrorDivisionByZero: "Nulou nelze dělit.", + ErrorRetry: "Zkuste to znovu.", + ResultPrefix: "Výsledek je:", + OperationPrefix: "Operace", + ErrorPrefix: "Chyba:", + StartupTitle: "*** HotelTime Calculator ***", + StartupExitOptions: "Ctrl+C - okamžitý konec programu", + StartupAvailableOptions: "Dostupné přepínače:", + StartupDecimalPlaces: "Počet desetinných míst výsledku", + StartupDefault: "výchozí", + StartupCurrent: "aktuální", + PromptSameInputs: "Spustit novou operaci se stejnými čísly?" + ), + }; + + public static Locale Current => Locales[DefaultLocale]; + } +} diff --git a/refactoring/Operation.cs b/refactoring/Operation.cs new file mode 100644 index 0000000..0753f76 --- /dev/null +++ b/refactoring/Operation.cs @@ -0,0 +1,10 @@ +using System; + +namespace HotelTimeCalculator +{ + readonly record struct Operation( + string Key, + string Label, + Func> Execute + ); +} diff --git a/refactoring/Operations.cs b/refactoring/Operations.cs new file mode 100644 index 0000000..5cc426d --- /dev/null +++ b/refactoring/Operations.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace HotelTimeCalculator +{ + // Operation registry - adding a new operation = adding a single line. + + static class Operations + { + public static readonly IReadOnlyList All = + [ + new("1", "a + b", (a, b) => Result.Ok(a + b)), + new("2", "a - b", (a, b) => Result.Ok(a - b)), + new("3", "a * b", (a, b) => Result.Ok(a * b)), + new( + "4", + "a / b", + (a, b) => + b == 0 + ? Result.Err(Messages.Current.ErrorDivisionByZero) + : Result.Ok(a / b) + ), + ]; + } +} diff --git a/refactoring/Program.cs b/refactoring/Program.cs index 0001ce6..25fc7c2 100644 --- a/refactoring/Program.cs +++ b/refactoring/Program.cs @@ -1,82 +1,49 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace Fujtajbl +namespace HotelTimeCalculator { - class Program - { - static void Main(string[] args) - { -Start: - Console.WriteLine("Zadejte cislo A"); - var a = Int32.Parse(Console.ReadLine()); + // Entry point - orchestration only, no logic. + // Wires dependencies and runs the main loop. - Console.WriteLine("Zadejte cislo B"); - var b = Int32.Parse(Console.ReadLine()); + class Program + { + static void Main(string[] args) + { + var writer = new ConsoleWriter(Console.Out); + var input = new Input(Console.In, writer); + var cliArgs = CliArgs.Parse(args); -Select: - Console.Write(Environment.NewLine); - Console.Write("Vyberte operaci" + Environment.NewLine); - Console.Write("1: a + b" + Environment.NewLine); - Console.Write("2: a - b" + Environment.NewLine); - Console.Write("3: a * b" + Environment.NewLine); - Console.Write("4: a / b" + Environment.NewLine); + writer.PrintStartupInfo(CliArgs.DecimalPlaces.Default, cliArgs.DecimalPlaces); - var operace = Int32.Parse(Console.ReadLine()); - int found = 0; - string vysledek = null; + var running = true; - if (operace == 1) - { - vysledek = "Vysledek je = " + (a + b); - found = 1; - } - else - { - if (operace == 2) - { - vysledek = "Vysledek je = " + (a - b); - found = 2; - } - else - { - if (operace == 3) - { - vysledek = "Vysledek je = " + (a * b); - found = 3; - } - else - { - if (operace == 4) - { - vysledek = "Vysledek je = " + (a / b); - found = 4; - } - } - } - } + while (running) + { + var a = input.ReadDecimal(Messages.Current.PromptNumberA); + var b = input.ReadDecimal(Messages.Current.PromptNumberB); - if (found == 0) - { - Console.WriteLine("Neznama operace!"); - goto Select; - } - else - { - if (found == 1) Console.WriteLine("Operace a + b: " + vysledek); - else - if (found == 2) Console.WriteLine("Operace a - b: " + vysledek); - else - if (found == 3) Console.WriteLine("Operace a * b: " + vysledek); - else - if (found == 4) Console.WriteLine("Operace a / b: " + vysledek); - } + var continueWithSameInputs = true; + while (continueWithSameInputs) + { + var operation = input.SelectOperation(Operations.All); + var result = operation.Execute(a, b); - Console.WriteLine("Chcete spustit novy vypocet?"); - if (Console.ReadLine() == "a") goto Start; - } - } -} \ No newline at end of file + if (result.IsOk) + { + var formatted = result.Value.ToString($"F{cliArgs.DecimalPlaces}"); + writer.Success( + $"{Messages.Current.OperationPrefix} {operation.Label}: {Messages.Current.ResultPrefix} {formatted}" + ); + continueWithSameInputs = input.ReadYesNo(Messages.Current.PromptSameInputs); + } + else + { + writer.Error($"{Messages.Current.ErrorPrefix} {result.Error}"); + } + } + + running = input.ReadYesNo(Messages.Current.PromptNewCalculation); + } + } + } +} diff --git a/refactoring/Properties/AssemblyInfo.cs b/refactoring/Properties/AssemblyInfo.cs index 9988580..726afb6 100644 --- a/refactoring/Properties/AssemblyInfo.cs +++ b/refactoring/Properties/AssemblyInfo.cs @@ -5,12 +5,12 @@ // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. -[assembly: AssemblyTitle("Fujtajbl")] +[assembly: AssemblyTitle("HotelTimeCalculator")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Fujtajbl")] -[assembly: AssemblyCopyright("Copyright © 2022")] +[assembly: AssemblyProduct("HotelTimeCalculator")] +[assembly: AssemblyCopyright("Copyright © 2026")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] diff --git a/refactoring/Result.cs b/refactoring/Result.cs new file mode 100644 index 0000000..58b03a7 --- /dev/null +++ b/refactoring/Result.cs @@ -0,0 +1,11 @@ +namespace HotelTimeCalculator +{ + // Explicit error handling via Result type - no throwing on user input. + + readonly record struct Result(bool IsOk, T Value, string Error) + { + public static Result Ok(T value) => new(true, value, default); + + public static Result Err(string error) => new(false, default, error); + } +} From 4498407b0416e0b9fdb910eaa74475e7018a9cac Mon Sep 17 00:00:00 2001 From: Vaclav Vracovsky Date: Mon, 6 Apr 2026 10:55:39 +0200 Subject: [PATCH 3/5] Implement the maze --- Makefile | 9 +- README.md | 59 +++++++++++ hoteltime.sln | 47 +++++++++ maze/Cell.cs | 10 ++ maze/Direction.cs | 51 ++++++++++ maze/Dwarf.cs | 62 +++++++++++ maze/DwarfFactory.cs | 35 +++++++ maze/DwarfSpecs.cs | 39 +++++++ maze/Game.cs | 119 ++++++++++++++++++++++ maze/IConsoleDisplay.cs | 35 +++++++ maze/Makefile | 12 +++ maze/MazeGrid.cs | 43 ++++++++ maze/MazeLoader.cs | 59 +++++++++++ maze/MazeRenderer.cs | 75 ++++++++++++++ maze/MazeRunner.csproj | 13 +++ maze/Position.cs | 15 +++ maze/Program.cs | 51 ++++++++++ maze/{ => mazes}/Maze.dat | 0 maze/mazes/Maze2.dat | 41 ++++++++ maze/mazes/Maze3.dat | 21 ++++ maze/mazes/generate_maze.py | 53 ++++++++++ maze/strategies/CycleDetectingStrategy.cs | 39 +++++++ maze/strategies/IDwarfStrategy.cs | 17 ++++ maze/strategies/LeftHandStrategy.cs | 28 +++++ maze/strategies/PathFinderStrategy.cs | 82 +++++++++++++++ maze/strategies/RightHandStrategy.cs | 28 +++++ maze/strategies/TeleportStrategy.cs | 50 +++++++++ refactoring/ConsoleWriter.cs | 29 +++--- refactoring/HotelTimeCalculator.csproj | 4 + refactoring/IColorOutput.cs | 17 ++++ refactoring/Input.cs | 3 +- refactoring/Messages.cs | 7 ++ refactoring/Operation.cs | 1 + refactoring/Operations.cs | 9 +- refactoring/Program.cs | 6 +- {refactoring => shared}/Result.cs | 8 +- shared/Shared.csproj | 7 ++ 37 files changed, 1157 insertions(+), 27 deletions(-) create mode 100644 maze/Cell.cs create mode 100644 maze/Direction.cs create mode 100644 maze/Dwarf.cs create mode 100644 maze/DwarfFactory.cs create mode 100644 maze/DwarfSpecs.cs create mode 100644 maze/Game.cs create mode 100644 maze/IConsoleDisplay.cs create mode 100644 maze/Makefile create mode 100644 maze/MazeGrid.cs create mode 100644 maze/MazeLoader.cs create mode 100644 maze/MazeRenderer.cs create mode 100644 maze/MazeRunner.csproj create mode 100644 maze/Position.cs create mode 100644 maze/Program.cs rename maze/{ => mazes}/Maze.dat (100%) create mode 100644 maze/mazes/Maze2.dat create mode 100644 maze/mazes/Maze3.dat create mode 100644 maze/mazes/generate_maze.py create mode 100644 maze/strategies/CycleDetectingStrategy.cs create mode 100644 maze/strategies/IDwarfStrategy.cs create mode 100644 maze/strategies/LeftHandStrategy.cs create mode 100644 maze/strategies/PathFinderStrategy.cs create mode 100644 maze/strategies/RightHandStrategy.cs create mode 100644 maze/strategies/TeleportStrategy.cs create mode 100644 refactoring/IColorOutput.cs rename {refactoring => shared}/Result.cs (63%) create mode 100644 shared/Shared.csproj diff --git a/Makefile b/Makefile index e8ca041..f624db4 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,15 @@ -.PHONY: build run format +.PHONY: build run-maze run-refactoring format build: $(MAKE) -C refactoring build + $(MAKE) -C maze build -run: +run-refactoring: $(MAKE) -C refactoring run +run-maze: + $(MAKE) -C maze run MAZE=$(MAZE) + format: $(MAKE) -C refactoring format + $(MAKE) -C maze format diff --git a/README.md b/README.md index 5258a1e..8a5d5c7 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,62 @@ dotnet run -- --decimalPlaces 10 # result with 10 decimal places 4. When asked whether to run a new calculation: - `a`, `A`, `y`, `Y`, `ano`, `yes` or **Enter** (default: yes) to continue - `n`, `N`, `ne`, `no` to exit + +--- + +# MazeRunner + +Console animation of four dwarves navigating a maze using different strategies. + +## Getting started + +```bash +make run-maze # default maze (mazes/Maze.dat) +make run-maze MAZE=mazes/Maze2.dat # custom maze file +``` + +Or directly: + +```bash +cd maze +dotnet run --project MazeRunner.csproj -- mazes/Maze.dat +``` + +## Dwarves + +| Symbol | Name | Strategy | +|---|---|---| +| `L` | Zeman | Left-hand rule — always follows the left wall | +| `R` | Klaus | Right-hand rule — always follows the right wall | +| `T` | Babiš | Teleporter — walks randomly, teleports to finish at a random time | +| `P` | Masaryk | Pathfinder — computes shortest path via BFS at start, then follows it | + +Each dwarf is spawned with a 5 s delay after the previous one. + +## Maze format + +Maze files are plain text in `maze/mazes/`: + +| Character | Meaning | +|---|---| +| `#` | Wall | +| ` ` | Path | +| `S` | Start | +| `F` | Finish | + +## Adding a new maze + +Create a `.dat` file in `maze/mazes/` following the format above and run: + +```bash +make run-maze MAZE=mazes/YourMaze.dat +``` + +A 30×19 example is included as `mazes/Maze2.dat`. New mazes can be generated with the included script: + +```bash +cd maze/mazes +python3 generate_maze.py # 20×20, prints to stdout +python3 generate_maze.py 30 20 # 30 wide × 20 tall +python3 generate_maze.py 30 20 42 MyMaze.dat # with fixed seed, written to file +``` diff --git a/hoteltime.sln b/hoteltime.sln index f0a57f4..63c74b5 100644 --- a/hoteltime.sln +++ b/hoteltime.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 @@ -6,22 +7,68 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "refactoring", "refactoring" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotelTimeCalculator", "refactoring\HotelTimeCalculator.csproj", "{4052209C-18FC-6683-56E3-5E64701FD4EF}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "maze", "maze", "{3CF8DE81-F6D1-7474-733B-CEB3F8542B75}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MazeRunner", "maze\MazeRunner.csproj", "{004DE42F-90D8-40BC-ACA0-534E3214E2C6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "shared", "shared", "{4F52FD11-658E-A102-6CD3-7D7C16FFA15B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "shared\Shared.csproj", "{8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {4052209C-18FC-6683-56E3-5E64701FD4EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4052209C-18FC-6683-56E3-5E64701FD4EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Debug|x64.ActiveCfg = Debug|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Debug|x64.Build.0 = Debug|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Debug|x86.ActiveCfg = Debug|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Debug|x86.Build.0 = Debug|Any CPU {4052209C-18FC-6683-56E3-5E64701FD4EF}.Release|Any CPU.ActiveCfg = Release|Any CPU {4052209C-18FC-6683-56E3-5E64701FD4EF}.Release|Any CPU.Build.0 = Release|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Release|x64.ActiveCfg = Release|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Release|x64.Build.0 = Release|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Release|x86.ActiveCfg = Release|Any CPU + {4052209C-18FC-6683-56E3-5E64701FD4EF}.Release|x86.Build.0 = Release|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Debug|x64.Build.0 = Debug|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Debug|x86.Build.0 = Debug|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Release|Any CPU.Build.0 = Release|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Release|x64.ActiveCfg = Release|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Release|x64.Build.0 = Release|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Release|x86.ActiveCfg = Release|Any CPU + {004DE42F-90D8-40BC-ACA0-534E3214E2C6}.Release|x86.Build.0 = Release|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Debug|x64.ActiveCfg = Debug|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Debug|x64.Build.0 = Debug|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Debug|x86.ActiveCfg = Debug|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Debug|x86.Build.0 = Debug|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Release|Any CPU.Build.0 = Release|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Release|x64.ActiveCfg = Release|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Release|x64.Build.0 = Release|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Release|x86.ActiveCfg = Release|Any CPU + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {4052209C-18FC-6683-56E3-5E64701FD4EF} = {97EF2187-4C24-D818-2C49-4D787C71BD11} + {004DE42F-90D8-40BC-ACA0-534E3214E2C6} = {3CF8DE81-F6D1-7474-733B-CEB3F8542B75} + {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC} = {4F52FD11-658E-A102-6CD3-7D7C16FFA15B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3F766DA4-2526-48DE-BFD5-FF07C7F7062E} diff --git a/maze/Cell.cs b/maze/Cell.cs new file mode 100644 index 0000000..dc2210e --- /dev/null +++ b/maze/Cell.cs @@ -0,0 +1,10 @@ +namespace MazeRunner +{ + enum Cell + { + Path, + Wall, + Start, + Finish, + } +} diff --git a/maze/Direction.cs b/maze/Direction.cs new file mode 100644 index 0000000..8582215 --- /dev/null +++ b/maze/Direction.cs @@ -0,0 +1,51 @@ +namespace MazeRunner +{ + enum Direction + { + Up, + Down, + Left, + Right, + } + + static class DirectionExtensions + { + public static readonly Direction[] All = + [ + Direction.Up, + Direction.Down, + Direction.Left, + Direction.Right, + ]; + + public static Direction TurnLeft(this Direction d) => + d switch + { + Direction.Up => Direction.Left, + Direction.Left => Direction.Down, + Direction.Down => Direction.Right, + Direction.Right => Direction.Up, + _ => d, + }; + + public static Direction TurnRight(this Direction d) => + d switch + { + Direction.Up => Direction.Right, + Direction.Right => Direction.Down, + Direction.Down => Direction.Left, + Direction.Left => Direction.Up, + _ => d, + }; + + public static Direction Reverse(this Direction d) => + d switch + { + Direction.Up => Direction.Down, + Direction.Down => Direction.Up, + Direction.Left => Direction.Right, + Direction.Right => Direction.Left, + _ => d, + }; + } +} diff --git a/maze/Dwarf.cs b/maze/Dwarf.cs new file mode 100644 index 0000000..069d1cc --- /dev/null +++ b/maze/Dwarf.cs @@ -0,0 +1,62 @@ +using System; +using MazeRunner.Strategies; + +namespace MazeRunner +{ + // A dwarf navigating the maze using a pluggable strategy. + + class Dwarf( + string name, + char symbol, + ConsoleColor color, + Position start, + Direction initialFacing, + IDwarfStrategy strategy + ) + { + public string Name { get; } = name; + public char Symbol { get; } = symbol; + public ConsoleColor Color { get; } = color; + public Position Position { get; private set; } = start; + public Direction Facing { get; private set; } = initialFacing; + public bool HasFinished { get; private set; } + public bool IsStuck { get; private set; } + public string StuckReason { get; private set; } = ""; + public int StepsTaken { get; private set; } + + private readonly IDwarfStrategy _strategy = strategy; + + public void Step(MazeGrid maze) + { + if (HasFinished || IsStuck) + return; + + if (!_strategy.CanSolve) + { + MarkStuck("no path found"); + return; + } + + var (nextPos, nextFacing) = _strategy.NextStep(maze, Position, Facing); + + Position = nextPos; + Facing = nextFacing; + StepsTaken++; + + if (maze[Position] == Cell.Finish) + { + HasFinished = true; + return; + } + + if (_strategy.IsCycleDetected) + MarkStuck(_strategy.StuckReason ?? "cycle detected"); + } + + private void MarkStuck(string reason) + { + IsStuck = true; + StuckReason = reason; + } + } +} diff --git a/maze/DwarfFactory.cs b/maze/DwarfFactory.cs new file mode 100644 index 0000000..9a28a94 --- /dev/null +++ b/maze/DwarfFactory.cs @@ -0,0 +1,35 @@ +using System; +using MazeRunner.Strategies; + +namespace MazeRunner +{ + record DwarfSpec( + string Name, + string Description, + char Symbol, + ConsoleColor Color, + Func CreateStrategy + ); + + // Factory for creating dwarf instances from a spec. + // Wraps strategies that opt into cycle detection with the decorator. + + static class DwarfFactory + { + public static Dwarf Create(DwarfSpec spec, MazeGrid maze, Random rng) + { + var strategy = spec.CreateStrategy(maze, rng); + if (strategy.DetectCycles) + strategy = new CycleDetectingStrategy(strategy); + + return new( + name: spec.Name, + symbol: spec.Symbol, + color: spec.Color, + start: maze.Start, + initialFacing: Direction.Down, + strategy: strategy + ); + } + } +} diff --git a/maze/DwarfSpecs.cs b/maze/DwarfSpecs.cs new file mode 100644 index 0000000..b23ab8a --- /dev/null +++ b/maze/DwarfSpecs.cs @@ -0,0 +1,39 @@ +using System; +using MazeRunner.Strategies; + +namespace MazeRunner +{ + // All known dwarf presets - add new dwarves here. + + static class DwarfSpecs + { + public static readonly DwarfSpec LeftHand = new( + "Zeman", + "Always to the left!", + 'L', + ConsoleColor.Yellow, + (_, _) => new LeftHandStrategy() + ); + public static readonly DwarfSpec RightHand = new( + "Klaus", + "To the left, or with Klaus!", + 'R', + ConsoleColor.Cyan, + (_, _) => new RightHandStrategy() + ); + public static readonly DwarfSpec Teleporter = new( + "Babiš", + "Always spawns somewhere where you don't expect him!", + 'T', + ConsoleColor.Magenta, + (_, rng) => new TeleportStrategy(rng) + ); + public static readonly DwarfSpec PathFinder = new( + "Masaryk", + "I have a plan, and I will follow it!", + 'P', + ConsoleColor.Green, + (maze, _) => new PathFinderStrategy(maze) + ); + } +} diff --git a/maze/Game.cs b/maze/Game.cs new file mode 100644 index 0000000..b20af16 --- /dev/null +++ b/maze/Game.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.Threading; + +namespace MazeRunner +{ + // Game orchestration - spawns dwarves with delay, runs steps, waits until all finish. + + class Game + { + private const int StepDelayMs = 100; + private const int SpawnDelayMs = 5000; + private const int SpawnDelaySteps = SpawnDelayMs / StepDelayMs; + + private readonly MazeGrid _maze; + private readonly MazeRenderer _renderer; + private readonly IConsoleDisplay _display; + private readonly List _dwarves; + private readonly int[] _spawnAtStep; + private int _stepCount; + + public Game(MazeGrid maze, MazeRenderer renderer, IConsoleDisplay display, List dwarves) + { + _maze = maze; + _renderer = renderer; + _display = display; + _dwarves = dwarves; + _spawnAtStep = new int[dwarves.Count]; + for (var i = 0; i < dwarves.Count; i++) + _spawnAtStep[i] = i * SpawnDelaySteps; + } + + private int _lastWindowWidth; + private int _lastWindowHeight; + + public void Run() + { + _lastWindowWidth = _display.WindowWidth; + _lastWindowHeight = _display.WindowHeight; + _renderer.DrawFull(); + + while (HasActiveDwarves()) + { + if (CheckResize()) + _renderer.DrawFull(); + + for (var i = 0; i < _dwarves.Count; i++) + { + var dwarf = _dwarves[i]; + if (_stepCount < _spawnAtStep[i] || dwarf.HasFinished || dwarf.IsStuck) + continue; + + var prevPos = dwarf.Position; + dwarf.Step(_maze); + + if (prevPos != dwarf.Position) + { + _renderer.ClearDwarf(prevPos); + _renderer.DrawDwarf(dwarf.Position, dwarf.Symbol, dwarf.Color); + } + + if (dwarf.HasFinished) + _renderer.ClearDwarf(dwarf.Position); + } + + DrawStatus(); + + Thread.Sleep(StepDelayMs); + _stepCount++; + } + + DrawStatus(); + } + + private bool CheckResize() + { + if ( + _display.WindowWidth == _lastWindowWidth + && _display.WindowHeight == _lastWindowHeight + ) + return false; + _lastWindowWidth = _display.WindowWidth; + _lastWindowHeight = _display.WindowHeight; + return true; + } + + private bool HasActiveDwarves() + { + for (var i = 0; i < _dwarves.Count; i++) + { + if (!_dwarves[i].HasFinished && !_dwarves[i].IsStuck) + return true; + } + return false; + } + + private void DrawStatus() + { + for (var i = 0; i < _dwarves.Count; i++) + { + var status = FormatDwarfStatus(_dwarves[i], _spawnAtStep[i]); + _renderer.WriteStatus(i + 1, status, _dwarves[i].Color); + } + } + + private string FormatDwarfStatus(Dwarf d, int spawnStep) + { + if (_stepCount < spawnStep) + return $" {d.Symbol} {d.Name}: waiting ({(spawnStep - _stepCount) * StepDelayMs / 1000}s)"; + + if (d.HasFinished) + return $" {d.Symbol} {d.Name}: FINISHED! ({d.StepsTaken * StepDelayMs / 1000.0:F1}s, {d.StepsTaken} steps)"; + + if (d.IsStuck) + return $" {d.Symbol} {d.Name}: STUCK ({d.StuckReason}) after {d.StepsTaken} steps"; + + return $" {d.Symbol} {d.Name}: ({d.Position.Row}, {d.Position.Col})"; + } + } +} diff --git a/maze/IConsoleDisplay.cs b/maze/IConsoleDisplay.cs new file mode 100644 index 0000000..88a198b --- /dev/null +++ b/maze/IConsoleDisplay.cs @@ -0,0 +1,35 @@ +using System; + +namespace MazeRunner +{ + interface IConsoleDisplay + { + int WindowWidth { get; } + int WindowHeight { get; } + bool CursorVisible { set; } + void SetCursorPosition(int left, int top); + void SetColor(ConsoleColor color); + void ResetColor(); + void Write(char ch); + void Write(string text); + void Clear(); + } + + class ConsoleDisplay : IConsoleDisplay + { + public int WindowWidth => Console.WindowWidth; + public int WindowHeight => Console.WindowHeight; + + public bool CursorVisible + { + set => Console.CursorVisible = value; + } + + public void SetCursorPosition(int left, int top) => Console.SetCursorPosition(left, top); + public void SetColor(ConsoleColor color) => Console.ForegroundColor = color; + public void ResetColor() => Console.ResetColor(); + public void Write(char ch) => Console.Write(ch); + public void Write(string text) => Console.Write(text); + public void Clear() => Console.Clear(); + } +} diff --git a/maze/Makefile b/maze/Makefile new file mode 100644 index 0000000..6b27c64 --- /dev/null +++ b/maze/Makefile @@ -0,0 +1,12 @@ +.PHONY: build run format + +build: + dotnet build ../hoteltime.sln + +MAZE ?= mazes/Maze.dat + +run: + dotnet run --project MazeRunner.csproj -- $(MAZE) + +format: + csharpier format . diff --git a/maze/MazeGrid.cs b/maze/MazeGrid.cs new file mode 100644 index 0000000..4424491 --- /dev/null +++ b/maze/MazeGrid.cs @@ -0,0 +1,43 @@ +namespace MazeRunner +{ + class MazeGrid + { + private readonly Cell[,] _cells; + + public int Height { get; } + public int Width { get; } + public Position Start { get; } + public Position Finish { get; } + + public MazeGrid(Cell[,] cells, Position start, Position finish) + { + _cells = cells; + Height = cells.GetLength(0); + Width = cells.GetLength(1); + Start = start; + Finish = finish; + } + + public Cell this[Position p] => + IsInBounds(p) + ? _cells[p.Row, p.Col] + : throw new System.ArgumentOutOfRangeException( + nameof(p), + $"Position ({p.Row}, {p.Col}) is out of bounds ({Height}x{Width})." + ); + + public bool IsInBounds(Position p) => + p.Row >= 0 && p.Row < Height && p.Col >= 0 && p.Col < Width; + + public bool IsPathClear(Position p) => IsInBounds(p) && _cells[p.Row, p.Col] != Cell.Wall; + + public char CellChar(Position p) => + _cells[p.Row, p.Col] switch + { + Cell.Wall => '#', + Cell.Start => 'S', + Cell.Finish => 'F', + _ => ' ', + }; + } +} diff --git a/maze/MazeLoader.cs b/maze/MazeLoader.cs new file mode 100644 index 0000000..3fc9660 --- /dev/null +++ b/maze/MazeLoader.cs @@ -0,0 +1,59 @@ +using System.IO; +using Shared; + +namespace MazeRunner +{ + static class MazeLoader + { + private const char WallChar = '#'; + private const char StartChar = 'S'; + private const char FinishChar = 'F'; + + public static Result Load(string filePath) + { + if (!File.Exists(filePath)) + return Result.Err($"Maze file not found: {filePath}"); + + var lines = File.ReadAllLines(filePath); + var height = lines.Length; + var width = 0; + + for (var i = 0; i < height; i++) + { + if (lines[i].Length > width) + width = lines[i].Length; + } + + var cells = new Cell[height, width]; + Position? start = null; + Position? finish = null; + + for (var row = 0; row < height; row++) + { + for (var col = 0; col < lines[row].Length; col++) + { + var ch = lines[row][col]; + cells[row, col] = ch switch + { + WallChar => Cell.Wall, + StartChar => Cell.Start, + FinishChar => Cell.Finish, + _ => Cell.Path, + }; + + if (ch == StartChar) + start = new Position(row, col); + else if (ch == FinishChar) + finish = new Position(row, col); + } + } + + if (start is null) + return Result.Err("Maze has no start position (S)."); + if (finish is null) + return Result.Err("Maze has no finish position (F)."); + + return Result.Ok(new MazeGrid(cells, start.Value, finish.Value)); + } + } +} diff --git a/maze/MazeRenderer.cs b/maze/MazeRenderer.cs new file mode 100644 index 0000000..a382c06 --- /dev/null +++ b/maze/MazeRenderer.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +namespace MazeRunner +{ + // Renders the maze to console. Only redraws cells that changed (no full repaint). + + class MazeRenderer + { + private readonly MazeGrid _maze; + private readonly IConsoleDisplay _display; + private readonly Dictionary _rendered = new(); + + public MazeRenderer(MazeGrid maze, IConsoleDisplay display) + { + _maze = maze; + _display = display; + } + + public void DrawFull() + { + _display.CursorVisible = false; + _display.Clear(); + + for (var row = 0; row < _maze.Height; row++) + { + for (var col = 0; col < _maze.Width; col++) + { + var p = new Position(row, col); + var ch = _maze.CellChar(p); + WriteAt(p, ch, ColorForCell(_maze[p])); + _rendered[p] = ch; + } + } + } + + public void DrawDwarf(Position position, char symbol, ConsoleColor color) + { + WriteAt(position, symbol, color); + _rendered[position] = symbol; + } + + public void ClearDwarf(Position position) + { + var ch = _maze.CellChar(position); + WriteAt(position, ch, ColorForCell(_maze[position])); + _rendered[position] = ch; + } + + public void WriteStatus(int lineOffset, string message, ConsoleColor color) + { + _display.SetCursorPosition(0, _maze.Height + lineOffset); + _display.SetColor(color); + _display.Write(message.PadRight(_display.WindowWidth - 1)); + _display.ResetColor(); + } + + private void WriteAt(Position p, char ch, ConsoleColor color) + { + _display.SetCursorPosition(p.Col, p.Row); + _display.SetColor(color); + _display.Write(ch); + _display.ResetColor(); + } + + private static ConsoleColor ColorForCell(Cell cell) => + cell switch + { + Cell.Wall => ConsoleColor.DarkGray, + Cell.Start => ConsoleColor.Green, + Cell.Finish => ConsoleColor.Red, + _ => ConsoleColor.Black, + }; + } +} diff --git a/maze/MazeRunner.csproj b/maze/MazeRunner.csproj new file mode 100644 index 0000000..6ca9a76 --- /dev/null +++ b/maze/MazeRunner.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + MazeRunner + MazeRunner + enable + disable + + + + + diff --git a/maze/Position.cs b/maze/Position.cs new file mode 100644 index 0000000..ce137d7 --- /dev/null +++ b/maze/Position.cs @@ -0,0 +1,15 @@ +namespace MazeRunner +{ + readonly record struct Position(int Row, int Col) + { + public Position Move(Direction direction) => + direction switch + { + Direction.Up => new(Row - 1, Col), + Direction.Down => new(Row + 1, Col), + Direction.Left => new(Row, Col - 1), + Direction.Right => new(Row, Col + 1), + _ => this, + }; + } +} diff --git a/maze/Program.cs b/maze/Program.cs new file mode 100644 index 0000000..e203c05 --- /dev/null +++ b/maze/Program.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace MazeRunner +{ + // Entry point - wires dependencies and starts the game. + + class Program + { + private const string DefaultMazeFile = "mazes/Maze.dat"; + + static void Main(string[] args) + { + var mazeFile = args.Length > 0 ? args[0] : DefaultMazeFile; + + var result = MazeLoader.Load(mazeFile); + if (!result.IsOk) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(result.Error); + Console.ResetColor(); + return; + } + + var maze = result.Value; + var rng = new Random(); + var display = new ConsoleDisplay(); + + var renderer = new MazeRenderer(maze, display); + var dwarves = new List + { + DwarfFactory.Create(DwarfSpecs.LeftHand, maze, rng), + DwarfFactory.Create(DwarfSpecs.RightHand, maze, rng), + DwarfFactory.Create(DwarfSpecs.Teleporter, maze, rng), + DwarfFactory.Create(DwarfSpecs.PathFinder, maze, rng), + }; + + var game = new Game(maze, renderer, display, dwarves); + game.Run(); + + var allFinished = dwarves.TrueForAll(d => d.HasFinished); + var finalMessage = allFinished + ? "All dwarves reached the finish!" + : "Simulation ended. Some dwarves got stuck."; + var finalColor = allFinished ? ConsoleColor.Green : ConsoleColor.Yellow; + + renderer.WriteStatus(dwarves.Count + 2, finalMessage, finalColor); + display.CursorVisible = true; + } + } +} diff --git a/maze/Maze.dat b/maze/mazes/Maze.dat similarity index 100% rename from maze/Maze.dat rename to maze/mazes/Maze.dat diff --git a/maze/mazes/Maze2.dat b/maze/mazes/Maze2.dat new file mode 100644 index 0000000..1b2413c --- /dev/null +++ b/maze/mazes/Maze2.dat @@ -0,0 +1,41 @@ +############################################################# +### # # # # # # #S# +# # ### ### ### ##### ##### ####### # # # # ### ### ##### # # +# # # # # # # # # # # # # # # # # # # +# # # ### # ########### ### # ### # ##### # # # ####### # # # +# # # # # # # # # # # # # # # # # +# # ### ############# ### # # # # # # # ##### ####### ##### # +# # # # # # # # # # # # # +# ### ##### # ######### ####### ######### # ### ####### # ### +# # # # # # # # # # # # # # +# # ### ### ### # # ### # ####### ########### ### ### # ### # +# # # # # # # # # # # # # # # # # +# ####### # ### # ### ##### # ######### ####### ### # # # # # +# # # # # # # # # # # # # # +# ##### # # # ### # ### ### ##### ### # # ####### # ######### +# # # # # # # # # # # # # # # # # # # +##### ####### # # # ##### ### # ### ####### # # # # # # # # # +# # # # # # # # # # # # # # # # # +### # # ####### # ### # ### ##### ########### # ########### # +# # # # # # # # # # # # # +# ### # # ##### ####### ########### # # # ########### # ### # +# # # # # # # # # # # # # +# # ### # # # ######### # ######### ### ### # ### # ##### # # +# # # # # # # # # # # # # # # # # # # +# ######### ### ### # ####### # ##### ### # # # # ### ##### # +# # # # # # # # # # # # # # +# ##### ##### ### ### # ### ### ##### ######### ####### # ### +# # # # # # # # # # # # # +##### ######### ####### # ### # # # ### # ##### ######### # # +# # # # # # # # # # # # # # # # +# # ##### # ##### ### # ### # # # # # ### ####### ##### ### # +# # # # # # # # # # # # # # # # +# ### ##### # ##### ####### # ##### ##### # ####### # # ### # +# # # # # # # # # # # # # # # # +### # ########### ##### ##### # # # # ####### # ##### ##### # +# # # # # # # # # # # # # # # # +##### ####### # # # ##### ### # ### ####### # # # # # # # # # +# # # # # # # # # # # # # # # # # # # +# ### ##### ####### # ### # ####### # # ### # ### # ### # # # +# # # # # # # # F# +############################################################# diff --git a/maze/mazes/Maze3.dat b/maze/mazes/Maze3.dat new file mode 100644 index 0000000..13123a8 --- /dev/null +++ b/maze/mazes/Maze3.dat @@ -0,0 +1,21 @@ +##################### +#S# # # +# # # ############# # +# # # # # +# # ### ### # ##### # +# # # # # # # +# # ### ### ### # ### +# # # # # # # # +# ### # # # ### # # # +# # # # # # # # +# # ##### # # ##### # +# # # # # # +# ### ####### # ##### +# # # # # # +# ##### ####### # ### +# # # # # # +##### # # ### ### # # +# # # # # # # # +# # # # # # ####### # +# # # F# +##################### diff --git a/maze/mazes/generate_maze.py b/maze/mazes/generate_maze.py new file mode 100644 index 0000000..fb02031 --- /dev/null +++ b/maze/mazes/generate_maze.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Maze generator using recursive backtracking. + +Usage: + python3 generate_maze.py # 20x20, output to stdout + python3 generate_maze.py 30 19 # 30 wide x 19 tall + python3 generate_maze.py 30 19 42 # with fixed random seed + python3 generate_maze.py 30 19 42 MyMaze.dat # write to file +""" + +import random +import sys + + +def generate(width: int, height: int, seed: int | None = None) -> str: + rng = random.Random(seed) + grid = [["#"] * (2 * width + 1) for _ in range(2 * height + 1)] + + def carve(r: int, c: int) -> None: + dirs = [(0, 1), (0, -1), (1, 0), (-1, 0)] + rng.shuffle(dirs) + for dr, dc in dirs: + nr, nc = r + dr, c + dc + if 0 <= nr < height and 0 <= nc < width and grid[2 * nr + 1][2 * nc + 1] == "#": + grid[2 * r + 1 + dr][2 * c + 1 + dc] = " " + grid[2 * nr + 1][2 * nc + 1] = " " + carve(nr, nc) + + grid[1][1] = " " + carve(0, 0) + + grid[1][1] = "S" + grid[2 * height - 1][2 * width - 1] = "F" + + return "\n".join("".join(row) for row in grid) + "\n" + + +if __name__ == "__main__": + args = sys.argv[1:] + width = int(args[0]) if len(args) > 0 else 20 + height = int(args[1]) if len(args) > 1 else 20 + seed = int(args[2]) if len(args) > 2 else None + output = args[3] if len(args) > 3 else None + + maze = generate(width, height, seed) + + if output: + with open(output, "w") as f: + f.write(maze) + print(f"Written {width}x{height} maze to {output}") + else: + print(maze, end="") diff --git a/maze/strategies/CycleDetectingStrategy.cs b/maze/strategies/CycleDetectingStrategy.cs new file mode 100644 index 0000000..032a456 --- /dev/null +++ b/maze/strategies/CycleDetectingStrategy.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; + +namespace MazeRunner.Strategies +{ + // Decorator - wraps any strategy and detects cycles or no-movement stalls. + + class CycleDetectingStrategy(IDwarfStrategy inner) : IDwarfStrategy + { + private readonly HashSet<(Position, Direction)> _visitedStates = new(); + private int _noMoveCount; + + public bool CanSolve => inner.CanSolve; + public bool DetectCycles => false; + public bool IsCycleDetected { get; private set; } + public string? StuckReason { get; private set; } + + public (Position, Direction) NextStep(MazeGrid maze, Position current, Direction facing) + { + var (nextPos, nextFacing) = inner.NextStep(maze, current, facing); + + if (nextPos != current) + { + _noMoveCount = 0; + if (!_visitedStates.Add((nextPos, nextFacing))) + { + IsCycleDetected = true; + StuckReason = "cycle detected"; + } + } + else if (++_noMoveCount >= 5) + { + IsCycleDetected = true; + StuckReason = "no movement"; + } + + return (nextPos, nextFacing); + } + } +} diff --git a/maze/strategies/IDwarfStrategy.cs b/maze/strategies/IDwarfStrategy.cs new file mode 100644 index 0000000..615692b --- /dev/null +++ b/maze/strategies/IDwarfStrategy.cs @@ -0,0 +1,17 @@ +namespace MazeRunner.Strategies +{ + // Strategy interface - each dwarf type implements different movement algorithm. + + interface IDwarfStrategy + { + (Position nextPos, Direction nextFacing) NextStep( + MazeGrid maze, + Position current, + Direction facing + ); + bool CanSolve => true; + bool DetectCycles => true; + bool IsCycleDetected => false; + string? StuckReason => null; + } +} diff --git a/maze/strategies/LeftHandStrategy.cs b/maze/strategies/LeftHandStrategy.cs new file mode 100644 index 0000000..2746e2e --- /dev/null +++ b/maze/strategies/LeftHandStrategy.cs @@ -0,0 +1,28 @@ +namespace MazeRunner.Strategies +{ + // Wall-following strategy - follows the left wall. + // Try: left -> forward -> right -> back + + class LeftHandStrategy : IDwarfStrategy + { + public (Position, Direction) NextStep(MazeGrid maze, Position current, Direction facing) + { + var left = facing.TurnLeft(); + if (maze.IsPathClear(current.Move(left))) + return (current.Move(left), left); + + if (maze.IsPathClear(current.Move(facing))) + return (current.Move(facing), facing); + + var right = facing.TurnRight(); + if (maze.IsPathClear(current.Move(right))) + return (current.Move(right), right); + + var back = facing.Reverse(); + if (maze.IsPathClear(current.Move(back))) + return (current.Move(back), back); + + return (current, facing); + } + } +} diff --git a/maze/strategies/PathFinderStrategy.cs b/maze/strategies/PathFinderStrategy.cs new file mode 100644 index 0000000..c529bb6 --- /dev/null +++ b/maze/strategies/PathFinderStrategy.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; + +namespace MazeRunner.Strategies +{ + // Finds shortest path at construction, then follows it step by step. + // Used BFS algorithm + + class PathFinderStrategy(MazeGrid maze) : IDwarfStrategy + { + private readonly Queue _path = FindPath(maze); + + public bool CanSolve => _path.Count > 0; + + public (Position, Direction) NextStep(MazeGrid maze, Position current, Direction facing) + { + if (_path.Count > 0) + { + var next = _path.Dequeue(); + var newFacing = InferDirection(current, next) ?? facing; + return (next, newFacing); + } + return (current, facing); + } + + private static Direction? InferDirection(Position from, Position to) => + (to.Row - from.Row, to.Col - from.Col) switch + { + (-1, 0) => Direction.Up, + (1, 0) => Direction.Down, + (0, -1) => Direction.Left, + (0, 1) => Direction.Right, + _ => null, + }; + + private static Queue FindPath(MazeGrid maze) + { + var start = maze.Start; + var finish = maze.Finish; + var visited = new HashSet { start }; + var parent = new Dictionary(); + var queue = new Queue(); + queue.Enqueue(start); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current == finish) + return ReconstructPath(parent, start, finish); + + foreach (var dir in DirectionExtensions.All) + { + var neighbor = current.Move(dir); + if (maze.IsPathClear(neighbor) && visited.Add(neighbor)) + { + parent[neighbor] = current; + queue.Enqueue(neighbor); + } + } + } + + return new Queue(); + } + + private static Queue ReconstructPath( + Dictionary parent, + Position start, + Position finish + ) + { + var stack = new Stack(); + var current = finish; + + while (current != start) + { + stack.Push(current); + current = parent[current]; + } + + return new Queue(stack); + } + } +} diff --git a/maze/strategies/RightHandStrategy.cs b/maze/strategies/RightHandStrategy.cs new file mode 100644 index 0000000..f44a700 --- /dev/null +++ b/maze/strategies/RightHandStrategy.cs @@ -0,0 +1,28 @@ +namespace MazeRunner.Strategies +{ + // Wall-following strategy - follows the right wall. + // Try: right -> forward -> left -> back + + class RightHandStrategy : IDwarfStrategy + { + public (Position, Direction) NextStep(MazeGrid maze, Position current, Direction facing) + { + var right = facing.TurnRight(); + if (maze.IsPathClear(current.Move(right))) + return (current.Move(right), right); + + if (maze.IsPathClear(current.Move(facing))) + return (current.Move(facing), facing); + + var left = facing.TurnLeft(); + if (maze.IsPathClear(current.Move(left))) + return (current.Move(left), left); + + var back = facing.Reverse(); + if (maze.IsPathClear(current.Move(back))) + return (current.Move(back), back); + + return (current, facing); + } + } +} diff --git a/maze/strategies/TeleportStrategy.cs b/maze/strategies/TeleportStrategy.cs new file mode 100644 index 0000000..13e683a --- /dev/null +++ b/maze/strategies/TeleportStrategy.cs @@ -0,0 +1,50 @@ +using System; + +namespace MazeRunner.Strategies +{ + // Teleport strategy - walks normally, but at random intervals teleports to finish. + + class TeleportStrategy : IDwarfStrategy + { + private readonly Random _rng; + private readonly int _minSteps; + private readonly int _maxSteps; + private int _stepsUntilTeleport; + + public TeleportStrategy(Random rng, int minSteps = 10, int maxSteps = 50) + { + _rng = rng; + _minSteps = minSteps; + _maxSteps = maxSteps; + _stepsUntilTeleport = _rng.Next(_minSteps, _maxSteps); + } + + public bool DetectCycles => false; + + public (Position, Direction) NextStep(MazeGrid maze, Position current, Direction facing) + { + _stepsUntilTeleport--; + + if (_stepsUntilTeleport <= 0) + return (maze.Finish, facing); + + var forward = current.Move(facing); + if (maze.IsPathClear(forward)) + return (forward, facing); + + var left = facing.TurnLeft(); + if (maze.IsPathClear(current.Move(left))) + return (current.Move(left), left); + + var right = facing.TurnRight(); + if (maze.IsPathClear(current.Move(right))) + return (current.Move(right), right); + + var back = facing.Reverse(); + if (maze.IsPathClear(current.Move(back))) + return (current.Move(back), back); + + return (current, facing); + } + } +} diff --git a/refactoring/ConsoleWriter.cs b/refactoring/ConsoleWriter.cs index a829d04..ca1a128 100644 --- a/refactoring/ConsoleWriter.cs +++ b/refactoring/ConsoleWriter.cs @@ -4,54 +4,55 @@ namespace HotelTimeCalculator { // Centralized colored output. All color decisions live here. - // TextWriter is injected - no direct Console dependency. + // TextWriter + IColorOutput are injected - no direct Console dependency. - class ConsoleWriter(TextWriter output) + class ConsoleWriter(TextWriter output, IColorOutput color) { private readonly TextWriter _out = output; + private readonly IColorOutput _color = color; public void Error(string message) { - Console.ForegroundColor = ConsoleColor.Red; + _color.SetColor(ConsoleColor.Red); _out.WriteLine($" {message}"); - Console.ResetColor(); + _color.ResetColor(); } public void Success(string message) { - Console.ForegroundColor = ConsoleColor.Green; + _color.SetColor(ConsoleColor.Green); _out.WriteLine(message); - Console.ResetColor(); + _color.ResetColor(); } public void Hint(string message) { - Console.ForegroundColor = ConsoleColor.DarkGray; + _color.SetColor(ConsoleColor.DarkGray); _out.Write(message); - Console.ResetColor(); + _color.ResetColor(); } public void Info(string message) { - Console.ForegroundColor = ConsoleColor.Cyan; + _color.SetColor(ConsoleColor.Cyan); _out.WriteLine(message); - Console.ResetColor(); + _color.ResetColor(); } public void Label(string message) { - Console.ForegroundColor = ConsoleColor.DarkCyan; + _color.SetColor(ConsoleColor.DarkCyan); _out.WriteLine(message); - Console.ResetColor(); + _color.ResetColor(); } public void Heading(string message) { - Console.ForegroundColor = ConsoleColor.DarkYellow; + _color.SetColor(ConsoleColor.DarkYellow); _out.WriteLine(); _out.WriteLine($"\x1b[1m{message}\x1b[22m"); _out.WriteLine(new string('-', message.Length + 4)); - Console.ResetColor(); + _color.ResetColor(); } public void PrintStartupInfo(int defaultDecimalPlaces, int currentDecimalPlaces) diff --git a/refactoring/HotelTimeCalculator.csproj b/refactoring/HotelTimeCalculator.csproj index 444fdd4..7864b43 100644 --- a/refactoring/HotelTimeCalculator.csproj +++ b/refactoring/HotelTimeCalculator.csproj @@ -4,7 +4,11 @@ net10.0 HotelTimeCalculator HotelTimeCalculator + enable disable false + + + diff --git a/refactoring/IColorOutput.cs b/refactoring/IColorOutput.cs new file mode 100644 index 0000000..6fb3c33 --- /dev/null +++ b/refactoring/IColorOutput.cs @@ -0,0 +1,17 @@ +using System; + +namespace HotelTimeCalculator +{ + interface IColorOutput + { + void SetColor(ConsoleColor color); + void ResetColor(); + } + + class ConsoleColorOutput : IColorOutput + { + public void SetColor(ConsoleColor color) => Console.ForegroundColor = color; + + public void ResetColor() => Console.ResetColor(); + } +} diff --git a/refactoring/Input.cs b/refactoring/Input.cs index c70670c..e1afe73 100644 --- a/refactoring/Input.cs +++ b/refactoring/Input.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using Shared; namespace HotelTimeCalculator { @@ -12,7 +13,7 @@ class Input(TextReader input, ConsoleWriter output) private readonly TextReader _in = input; private readonly ConsoleWriter _out = output; - public static Result ParseDecimal(string raw) + public static Result ParseDecimal(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return Result.Err(Messages.Current.ErrorEmptyInput); diff --git a/refactoring/Messages.cs b/refactoring/Messages.cs index 9c2fb88..610e98e 100644 --- a/refactoring/Messages.cs +++ b/refactoring/Messages.cs @@ -68,5 +68,12 @@ static class Messages }; public static Locale Current => Locales[DefaultLocale]; + + public static string ResolveError(string errorCode) => + errorCode switch + { + Operations.DivisionByZero => Current.ErrorDivisionByZero, + _ => errorCode, + }; } } diff --git a/refactoring/Operation.cs b/refactoring/Operation.cs index 0753f76..984e7f2 100644 --- a/refactoring/Operation.cs +++ b/refactoring/Operation.cs @@ -1,4 +1,5 @@ using System; +using Shared; namespace HotelTimeCalculator { diff --git a/refactoring/Operations.cs b/refactoring/Operations.cs index 5cc426d..8bbff68 100644 --- a/refactoring/Operations.cs +++ b/refactoring/Operations.cs @@ -1,11 +1,15 @@ using System.Collections.Generic; +using Shared; namespace HotelTimeCalculator { // Operation registry - adding a new operation = adding a single line. + // Operations return generic error codes, not localized messages. static class Operations { + public const string DivisionByZero = "division_by_zero"; + public static readonly IReadOnlyList All = [ new("1", "a + b", (a, b) => Result.Ok(a + b)), @@ -14,10 +18,7 @@ static class Operations new( "4", "a / b", - (a, b) => - b == 0 - ? Result.Err(Messages.Current.ErrorDivisionByZero) - : Result.Ok(a / b) + (a, b) => b == 0 ? Result.Err(DivisionByZero) : Result.Ok(a / b) ), ]; } diff --git a/refactoring/Program.cs b/refactoring/Program.cs index 25fc7c2..d20d0a8 100644 --- a/refactoring/Program.cs +++ b/refactoring/Program.cs @@ -9,7 +9,7 @@ class Program { static void Main(string[] args) { - var writer = new ConsoleWriter(Console.Out); + var writer = new ConsoleWriter(Console.Out, new ConsoleColorOutput()); var input = new Input(Console.In, writer); var cliArgs = CliArgs.Parse(args); @@ -38,7 +38,9 @@ static void Main(string[] args) } else { - writer.Error($"{Messages.Current.ErrorPrefix} {result.Error}"); + writer.Error( + $"{Messages.Current.ErrorPrefix} {Messages.ResolveError(result.Error ?? "")}" + ); } } diff --git a/refactoring/Result.cs b/shared/Result.cs similarity index 63% rename from refactoring/Result.cs rename to shared/Result.cs index 58b03a7..56d9fc3 100644 --- a/refactoring/Result.cs +++ b/shared/Result.cs @@ -1,11 +1,11 @@ -namespace HotelTimeCalculator +namespace Shared { // Explicit error handling via Result type - no throwing on user input. - readonly record struct Result(bool IsOk, T Value, string Error) + public readonly record struct Result(bool IsOk, T Value, string? Error) { - public static Result Ok(T value) => new(true, value, default); + public static Result Ok(T value) => new(true, value, null); - public static Result Err(string error) => new(false, default, error); + public static Result Err(string error) => new(false, default!, error); } } diff --git a/shared/Shared.csproj b/shared/Shared.csproj new file mode 100644 index 0000000..bd75d7b --- /dev/null +++ b/shared/Shared.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + disable + + From 3e3f7f70d77ee5c8ec276d6591cb0802b03078fe Mon Sep 17 00:00:00 2001 From: Vaclav Vracovsky Date: Tue, 7 Apr 2026 09:00:47 +0200 Subject: [PATCH 4/5] Unit tests for calculator nad maze runner This part was most different from the Node world, is it possible that I used some anti-pattern or something unusual, and I'd like to learn more about testing best practices - but for now, I believe that it could be sufficient for the task. But everything targetted here - make the functions testable, without need of complicated mocks, so I believe the base is good. --- .gitignore | 1 + Makefile | 40 ++- README.md | 77 ++++- hoteltime.sln | 32 ++ maze/Game.cs | 7 +- maze/IConsoleDisplay.cs | 5 + maze/MazeRunner.csproj | 3 + refactoring/HotelTimeCalculator.csproj | 3 + refactoring/Properties/AssemblyInfo.cs | 1 + .../HotelTimeCalculator.Tests/CliArgsTests.cs | 32 ++ .../ConsoleWriterTests.cs | 113 +++++++ .../HotelTimeCalculator.Tests.csproj | 23 ++ tests/HotelTimeCalculator.Tests/InputTests.cs | 103 ++++++ .../OperationsTests.cs | 69 ++++ .../ParseDecimalTests.cs | 39 +++ .../HotelTimeCalculator.Tests/ResultTests.cs | 20 ++ .../CycleDetectingStrategyTests.cs | 69 ++++ tests/MazeRunner.Tests/DwarfTests.cs | 98 ++++++ tests/MazeRunner.Tests/MazeGridTests.cs | 89 +++++ tests/MazeRunner.Tests/MazeLoaderTests.cs | 74 +++++ tests/MazeRunner.Tests/MazeRendererTests.cs | 105 ++++++ .../MazeRunner.Tests/MazeRunner.Tests.csproj | 23 ++ .../PositionAndDirectionTests.cs | 72 ++++ tests/MazeRunner.Tests/StrategyTests.cs | 312 ++++++++++++++++++ 24 files changed, 1401 insertions(+), 9 deletions(-) create mode 100644 tests/HotelTimeCalculator.Tests/CliArgsTests.cs create mode 100644 tests/HotelTimeCalculator.Tests/ConsoleWriterTests.cs create mode 100644 tests/HotelTimeCalculator.Tests/HotelTimeCalculator.Tests.csproj create mode 100644 tests/HotelTimeCalculator.Tests/InputTests.cs create mode 100644 tests/HotelTimeCalculator.Tests/OperationsTests.cs create mode 100644 tests/HotelTimeCalculator.Tests/ParseDecimalTests.cs create mode 100644 tests/HotelTimeCalculator.Tests/ResultTests.cs create mode 100644 tests/MazeRunner.Tests/CycleDetectingStrategyTests.cs create mode 100644 tests/MazeRunner.Tests/DwarfTests.cs create mode 100644 tests/MazeRunner.Tests/MazeGridTests.cs create mode 100644 tests/MazeRunner.Tests/MazeLoaderTests.cs create mode 100644 tests/MazeRunner.Tests/MazeRendererTests.cs create mode 100644 tests/MazeRunner.Tests/MazeRunner.Tests.csproj create mode 100644 tests/MazeRunner.Tests/PositionAndDirectionTests.cs create mode 100644 tests/MazeRunner.Tests/StrategyTests.cs diff --git a/.gitignore b/.gitignore index 9192ecc..b978901 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin/ obj/ .vscode/ +coverage/ diff --git a/Makefile b/Makefile index f624db4..007673b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ -.PHONY: build run-maze run-refactoring format +.PHONY: build run-maze run-refactoring format test coverage coverage-html coverage-check clean-coverage + +COVERAGE_DIR = ./coverage +COVERAGE_THRESHOLD = 75 build: $(MAKE) -C refactoring build @@ -13,3 +16,38 @@ run-maze: format: $(MAKE) -C refactoring format $(MAKE) -C maze format + csharpier format tests/ + +test: + dotnet test hoteltime.sln + +coverage: clean-coverage + dotnet test hoteltime.sln --collect:"XPlat Code Coverage" --results-directory $(COVERAGE_DIR) + @echo "Cobertura XML reports written to $(COVERAGE_DIR)/" + +coverage-html: coverage + reportgenerator \ + -reports:"$(COVERAGE_DIR)/**/coverage.cobertura.xml" \ + -targetdir:"$(COVERAGE_DIR)/html" \ + -reporttypes:Html + @echo "HTML report: $(COVERAGE_DIR)/html/index.html" + +coverage-check: coverage + reportgenerator \ + -reports:"$(COVERAGE_DIR)/**/coverage.cobertura.xml" \ + -targetdir:"$(COVERAGE_DIR)/summary" \ + -reporttypes:TextSummary + @cat $(COVERAGE_DIR)/summary/Summary.txt + @echo "" + @echo "--- Threshold check ($(COVERAGE_THRESHOLD)%) ---" + @line_coverage=$$(grep -oP 'Line coverage: \K[0-9.]+' $(COVERAGE_DIR)/summary/Summary.txt | head -1); \ + threshold=$(COVERAGE_THRESHOLD); \ + pass=$$(echo "$$line_coverage >= $$threshold" | bc -l); \ + if [ "$$pass" = "1" ]; then \ + echo "PASS: Line coverage $${line_coverage}% >= $${threshold}%"; \ + else \ + echo "FAIL: Line coverage $${line_coverage}% < $${threshold}%"; exit 1; \ + fi + +clean-coverage: + rm -rf $(COVERAGE_DIR) diff --git a/README.md b/README.md index 8a5d5c7..e0c760e 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,37 @@ Home assignment for https://www.hoteltime.com/ -# HotelTime Calculator - -Interactive console calculator supporting addition, subtraction, multiplication, and division of decimal numbers. - ## Prerequisites - [.NET SDK 10.0+](https://dotnet.microsoft.com/download) - CSharpier (code formatter): ```bash dotnet tool install --global csharpier + ``` +- ReportGenerator (HTML coverage reports - optional): + ```bash + dotnet tool install --global dotnet-reportgenerator-globaltool + ``` +- Add tools to PATH if needed: + ```bash export PATH="$PATH:$HOME/.dotnet/tools" # add to ~/.bashrc or ~/.zshrc ``` +## Quick start + +```bash +make build # build the solution +make test # run all unit tests +make run-refactoring # run the calculator +make run-maze # run the maze (default maze) +make format # format all C# files with CSharpier +``` + +--- + +# HotelTime Calculator + +Interactive console calculator supporting addition, subtraction, multiplication, and division of decimal numbers. + ## Getting started ```bash @@ -23,9 +42,8 @@ dotnet run Or using `make` from the project root: ```bash -make run # run the app -make build # build the solution -make format # format all C# files with CSharpier +make run-refactoring # run the app +make build # build the solution ``` ## CLI options @@ -109,3 +127,48 @@ python3 generate_maze.py # 20×20, prints to stdout python3 generate_maze.py 30 20 # 30 wide × 20 tall python3 generate_maze.py 30 20 42 MyMaze.dat # with fixed seed, written to file ``` + +--- + +# Testing & Coverage + +## Running tests + +```bash +make test # run all unit tests (xUnit) +``` + +## Coverage + +Coverage is collected via [coverlet](https://github.com/coverlet-coverage/coverlet) (Cobertura XML) and rendered via [ReportGenerator](https://github.com/danielpalme/ReportGenerator). + +```bash +make coverage # generate Cobertura XML reports into ./coverage/ +make coverage-html # XML + HTML report in ./coverage/html/index.html +make coverage-check # XML + per-class summary + threshold check (default 70%) +``` + +### Threshold + +The `coverage-check` target enforces a minimum line coverage percentage. Adjust it in the root `Makefile`: + +```makefile +COVERAGE_THRESHOLD ?= 77 +``` + +Override at runtime: + +```bash +make coverage-check COVERAGE_THRESHOLD=80 +``` + +If coverage is below the threshold, the build fails with a non-zero exit code - suitable for CI. + +### Coverage output + +| Path | Content | +|---|---| +| `coverage/**/coverage.cobertura.xml` | Raw Cobertura XML (one per test project) | +| `coverage/html/index.html` | Interactive HTML report (browse in browser) | + +The `coverage/` directory is gitignored. diff --git a/hoteltime.sln b/hoteltime.sln index 63c74b5..fed2b26 100644 --- a/hoteltime.sln +++ b/hoteltime.sln @@ -15,6 +15,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "shared", "shared", "{4F52FD EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "shared\Shared.csproj", "{8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotelTimeCalculator.Tests", "tests\HotelTimeCalculator.Tests\HotelTimeCalculator.Tests.csproj", "{476ECDBA-670B-4369-BBF8-A5ADE03C8517}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MazeRunner.Tests", "tests\MazeRunner.Tests\MazeRunner.Tests.csproj", "{A69C6CCF-5251-4511-A322-C421DEF6B272}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -61,6 +67,30 @@ Global {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Release|x64.Build.0 = Release|Any CPU {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Release|x86.ActiveCfg = Release|Any CPU {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC}.Release|x86.Build.0 = Release|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Debug|Any CPU.Build.0 = Debug|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Debug|x64.ActiveCfg = Debug|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Debug|x64.Build.0 = Debug|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Debug|x86.ActiveCfg = Debug|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Debug|x86.Build.0 = Debug|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Release|Any CPU.ActiveCfg = Release|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Release|Any CPU.Build.0 = Release|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Release|x64.ActiveCfg = Release|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Release|x64.Build.0 = Release|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Release|x86.ActiveCfg = Release|Any CPU + {476ECDBA-670B-4369-BBF8-A5ADE03C8517}.Release|x86.Build.0 = Release|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Debug|x64.ActiveCfg = Debug|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Debug|x64.Build.0 = Debug|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Debug|x86.ActiveCfg = Debug|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Debug|x86.Build.0 = Debug|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Release|Any CPU.Build.0 = Release|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Release|x64.ActiveCfg = Release|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Release|x64.Build.0 = Release|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Release|x86.ActiveCfg = Release|Any CPU + {A69C6CCF-5251-4511-A322-C421DEF6B272}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -69,6 +99,8 @@ Global {4052209C-18FC-6683-56E3-5E64701FD4EF} = {97EF2187-4C24-D818-2C49-4D787C71BD11} {004DE42F-90D8-40BC-ACA0-534E3214E2C6} = {3CF8DE81-F6D1-7474-733B-CEB3F8542B75} {8DF20E3C-9E0D-4C99-A769-6FF9D98FBCEC} = {4F52FD11-658E-A102-6CD3-7D7C16FFA15B} + {476ECDBA-670B-4369-BBF8-A5ADE03C8517} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {A69C6CCF-5251-4511-A322-C421DEF6B272} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3F766DA4-2526-48DE-BFD5-FF07C7F7062E} diff --git a/maze/Game.cs b/maze/Game.cs index b20af16..fc003fa 100644 --- a/maze/Game.cs +++ b/maze/Game.cs @@ -18,7 +18,12 @@ class Game private readonly int[] _spawnAtStep; private int _stepCount; - public Game(MazeGrid maze, MazeRenderer renderer, IConsoleDisplay display, List dwarves) + public Game( + MazeGrid maze, + MazeRenderer renderer, + IConsoleDisplay display, + List dwarves + ) { _maze = maze; _renderer = renderer; diff --git a/maze/IConsoleDisplay.cs b/maze/IConsoleDisplay.cs index 88a198b..d70761e 100644 --- a/maze/IConsoleDisplay.cs +++ b/maze/IConsoleDisplay.cs @@ -26,10 +26,15 @@ public bool CursorVisible } public void SetCursorPosition(int left, int top) => Console.SetCursorPosition(left, top); + public void SetColor(ConsoleColor color) => Console.ForegroundColor = color; + public void ResetColor() => Console.ResetColor(); + public void Write(char ch) => Console.Write(ch); + public void Write(string text) => Console.Write(text); + public void Clear() => Console.Clear(); } } diff --git a/maze/MazeRunner.csproj b/maze/MazeRunner.csproj index 6ca9a76..2b61473 100644 --- a/maze/MazeRunner.csproj +++ b/maze/MazeRunner.csproj @@ -7,6 +7,9 @@ enable disable + + + diff --git a/refactoring/HotelTimeCalculator.csproj b/refactoring/HotelTimeCalculator.csproj index 7864b43..71ca453 100644 --- a/refactoring/HotelTimeCalculator.csproj +++ b/refactoring/HotelTimeCalculator.csproj @@ -8,6 +8,9 @@ disable false + + + diff --git a/refactoring/Properties/AssemblyInfo.cs b/refactoring/Properties/AssemblyInfo.cs index 726afb6..9880434 100644 --- a/refactoring/Properties/AssemblyInfo.cs +++ b/refactoring/Properties/AssemblyInfo.cs @@ -13,6 +13,7 @@ [assembly: AssemblyCopyright("Copyright © 2026")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] +[assembly: InternalsVisibleTo("HotelTimeCalculator.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/tests/HotelTimeCalculator.Tests/CliArgsTests.cs b/tests/HotelTimeCalculator.Tests/CliArgsTests.cs new file mode 100644 index 0000000..4fe0636 --- /dev/null +++ b/tests/HotelTimeCalculator.Tests/CliArgsTests.cs @@ -0,0 +1,32 @@ +namespace HotelTimeCalculator.Tests +{ + public class CliArgsTests + { + [Fact] + public void NoArgs_ReturnsDefault() => Assert.Equal(2, CliArgs.Parse([]).DecimalPlaces); + + [Fact] + public void ValidDecimalPlaces_ParsesCorrectly() => + Assert.Equal(5, CliArgs.Parse(["--decimalPlaces", "5"]).DecimalPlaces); + + [Fact] + public void NegativeValue_ReturnsDefault() => + Assert.Equal(2, CliArgs.Parse(["--decimalPlaces", "-1"]).DecimalPlaces); + + [Fact] + public void NonNumericValue_ReturnsDefault() => + Assert.Equal(2, CliArgs.Parse(["--decimalPlaces", "abc"]).DecimalPlaces); + + [Fact] + public void MissingValue_ReturnsDefault() => + Assert.Equal(2, CliArgs.Parse(["--decimalPlaces"]).DecimalPlaces); + + [Fact] + public void Zero_IsValid() => + Assert.Equal(0, CliArgs.Parse(["--decimalPlaces", "0"]).DecimalPlaces); + + [Fact] + public void UnknownArgs_Ignored() => + Assert.Equal(2, CliArgs.Parse(["--unknown", "10"]).DecimalPlaces); + } +} diff --git a/tests/HotelTimeCalculator.Tests/ConsoleWriterTests.cs b/tests/HotelTimeCalculator.Tests/ConsoleWriterTests.cs new file mode 100644 index 0000000..6e2f1f3 --- /dev/null +++ b/tests/HotelTimeCalculator.Tests/ConsoleWriterTests.cs @@ -0,0 +1,113 @@ +namespace HotelTimeCalculator.Tests +{ + public class ConsoleWriterTests + { + private static (ConsoleWriter writer, StringWriter output, NoOpColorOutput color) Create() + { + var output = new StringWriter(); + var color = new NoOpColorOutput(); + return (new ConsoleWriter(output, color), output, color); + } + + [Fact] + public void Error_WritesIndentedMessage() + { + var (writer, output, _) = Create(); + writer.Error("fail"); + Assert.Contains(" fail", output.ToString()); + } + + [Fact] + public void Error_SetsRedColor() + { + var (writer, _, color) = Create(); + writer.Error("fail"); + Assert.Contains(ConsoleColor.Red, color.ColorsSet); + } + + [Fact] + public void Success_WritesMessage() + { + var (writer, output, _) = Create(); + writer.Success("ok"); + Assert.Contains("ok", output.ToString()); + } + + [Fact] + public void Hint_WritesMessage() + { + var (writer, output, _) = Create(); + writer.Hint("hint"); + Assert.Contains("hint", output.ToString()); + } + + [Fact] + public void Info_WritesMessage() + { + var (writer, output, _) = Create(); + writer.Info("info"); + Assert.Contains("info", output.ToString()); + } + + [Fact] + public void Label_WritesMessage() + { + var (writer, output, _) = Create(); + writer.Label("label"); + Assert.Contains("label", output.ToString()); + } + + [Fact] + public void Heading_WritesWithSeparator() + { + var (writer, output, _) = Create(); + writer.Heading("Title"); + var text = output.ToString(); + Assert.Contains("Title", text); + Assert.Contains("---", text); + } + + [Fact] + public void PrintStartupInfo_WritesTitle() + { + var (writer, output, _) = Create(); + writer.PrintStartupInfo(2, 4); + var text = output.ToString(); + Assert.Contains(Messages.Current.StartupTitle, text); + Assert.Contains("4", text); + } + + [Fact] + public void Write_WritesRaw() + { + var (writer, output, _) = Create(); + writer.Write("raw"); + Assert.Equal("raw", output.ToString()); + } + + [Fact] + public void WriteLine_WritesLine() + { + var (writer, output, _) = Create(); + writer.WriteLine("line"); + Assert.Contains("line", output.ToString()); + } + + [Fact] + public void WriteLine_Empty_WritesNewline() + { + var (writer, output, _) = Create(); + writer.WriteLine(); + Assert.Equal(Environment.NewLine, output.ToString()); + } + } + + internal class NoOpColorOutput : IColorOutput + { + public System.Collections.Generic.List ColorsSet { get; } = []; + + public void SetColor(ConsoleColor color) => ColorsSet.Add(color); + + public void ResetColor() { } + } +} diff --git a/tests/HotelTimeCalculator.Tests/HotelTimeCalculator.Tests.csproj b/tests/HotelTimeCalculator.Tests/HotelTimeCalculator.Tests.csproj new file mode 100644 index 0000000..35bd39e --- /dev/null +++ b/tests/HotelTimeCalculator.Tests/HotelTimeCalculator.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/tests/HotelTimeCalculator.Tests/InputTests.cs b/tests/HotelTimeCalculator.Tests/InputTests.cs new file mode 100644 index 0000000..efcbaa8 --- /dev/null +++ b/tests/HotelTimeCalculator.Tests/InputTests.cs @@ -0,0 +1,103 @@ +namespace HotelTimeCalculator.Tests +{ + public class InputReadDecimalTests + { + private static (Input input, StringWriter output) Create(string userInput) + { + var reader = new StringReader(userInput); + var sw = new StringWriter(); + var writer = new ConsoleWriter(sw, new NoOpColorOutput()); + return (new Input(reader, writer), sw); + } + + [Fact] + public void ReadDecimal_ValidInput_ReturnsValue() + { + var (input, _) = Create("42\n"); + Assert.Equal(42m, input.ReadDecimal("Enter: ")); + } + + [Fact] + public void ReadDecimal_InvalidThenValid_Retries() + { + var (input, output) = Create("abc\n7\n"); + Assert.Equal(7m, input.ReadDecimal("Enter: ")); + Assert.Contains(Messages.Current.ErrorRetry, output.ToString()); + } + } + + public class InputSelectOperationTests + { + private static (Input input, StringWriter output) Create(string userInput) + { + var reader = new StringReader(userInput); + var sw = new StringWriter(); + var writer = new ConsoleWriter(sw, new NoOpColorOutput()); + return (new Input(reader, writer), sw); + } + + [Fact] + public void SelectOperation_ValidKey_ReturnsOperation() + { + var (input, _) = Create("1\n"); + var op = input.SelectOperation(Operations.All); + Assert.Equal("1", op.Key); + } + + [Fact] + public void SelectOperation_InvalidThenValid_Retries() + { + var (input, output) = Create("x\n2\n"); + var op = input.SelectOperation(Operations.All); + Assert.Equal("2", op.Key); + Assert.Contains(Messages.Current.ErrorUnknownOperation, output.ToString()); + } + } + + public class InputReadYesNoTests + { + private static (Input input, StringWriter output) Create(string userInput) + { + var reader = new StringReader(userInput); + var sw = new StringWriter(); + var writer = new ConsoleWriter(sw, new NoOpColorOutput()); + return (new Input(reader, writer), sw); + } + + [Theory] + [InlineData("a", true)] + [InlineData("ano", true)] + [InlineData("y", true)] + [InlineData("yes", true)] + [InlineData("n", false)] + [InlineData("ne", false)] + [InlineData("no", false)] + public void ReadYesNo_ValidAnswers(string answer, bool expected) + { + var (input, _) = Create($"{answer}\n"); + Assert.Equal(expected, input.ReadYesNo("Continue?")); + } + + [Fact] + public void ReadYesNo_EmptyInput_ReturnsDefault() + { + var (input, _) = Create("\n"); + Assert.True(input.ReadYesNo("Continue?", defaultValue: true)); + } + + [Fact] + public void ReadYesNo_EmptyInput_ReturnsFalseWhenDefaultFalse() + { + var (input, _) = Create("\n"); + Assert.False(input.ReadYesNo("Continue?", defaultValue: false)); + } + + [Fact] + public void ReadYesNo_InvalidThenValid_Retries() + { + var (input, output) = Create("maybe\na\n"); + Assert.True(input.ReadYesNo("Continue?")); + Assert.Contains(Messages.Current.ErrorInvalidYesNo, output.ToString()); + } + } +} diff --git a/tests/HotelTimeCalculator.Tests/OperationsTests.cs b/tests/HotelTimeCalculator.Tests/OperationsTests.cs new file mode 100644 index 0000000..c2dd9eb --- /dev/null +++ b/tests/HotelTimeCalculator.Tests/OperationsTests.cs @@ -0,0 +1,69 @@ +namespace HotelTimeCalculator.Tests +{ + public class OperationsTests + { + [Fact] + public void Addition_ReturnsCorrectResult() + { + var op = Operations.All[0]; + var result = op.Execute(3, 4); + Assert.True(result.IsOk); + Assert.Equal(7m, result.Value); + } + + [Fact] + public void Subtraction_ReturnsCorrectResult() + { + var op = Operations.All[1]; + var result = op.Execute(10, 3); + Assert.True(result.IsOk); + Assert.Equal(7m, result.Value); + } + + [Fact] + public void Multiplication_ReturnsCorrectResult() + { + var op = Operations.All[2]; + var result = op.Execute(5, 6); + Assert.True(result.IsOk); + Assert.Equal(30m, result.Value); + } + + [Fact] + public void Division_ReturnsCorrectResult() + { + var op = Operations.All[3]; + var result = op.Execute(15, 3); + Assert.True(result.IsOk); + Assert.Equal(5m, result.Value); + } + + [Fact] + public void Division_ByZero_ReturnsError() + { + var op = Operations.All[3]; + var result = op.Execute(10, 0); + Assert.False(result.IsOk); + Assert.Equal(Operations.DivisionByZero, result.Error); + } + + [Fact] + public void MessagesResolveError_KnownCode_ReturnsLocalized() => + Assert.Equal( + Messages.Current.ErrorDivisionByZero, + Messages.ResolveError(Operations.DivisionByZero) + ); + + [Fact] + public void MessagesResolveError_UnknownCode_ReturnsSameCode() => + Assert.Equal("unknown_code", Messages.ResolveError("unknown_code")); + + [Fact] + public void AllOperations_HaveUniqueKeys() + { + var keys = new System.Collections.Generic.HashSet(); + foreach (var op in Operations.All) + Assert.True(keys.Add(op.Key), $"Duplicate key: {op.Key}"); + } + } +} diff --git a/tests/HotelTimeCalculator.Tests/ParseDecimalTests.cs b/tests/HotelTimeCalculator.Tests/ParseDecimalTests.cs new file mode 100644 index 0000000..b692484 --- /dev/null +++ b/tests/HotelTimeCalculator.Tests/ParseDecimalTests.cs @@ -0,0 +1,39 @@ +namespace HotelTimeCalculator.Tests +{ + public class ParseDecimalTests + { + [Theory] + [InlineData("42", 42)] + [InlineData("3.14", 3.14)] + [InlineData("0", 0)] + [InlineData("-7", -7)] + [InlineData("1000000", 1000000)] + public void ValidInput_ReturnsOk(string raw, decimal expected) + { + var result = Input.ParseDecimal(raw); + Assert.True(result.IsOk); + Assert.Equal(expected, result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void EmptyOrNull_ReturnsErr(string? raw) + { + var result = Input.ParseDecimal(raw); + Assert.False(result.IsOk); + } + + [Theory] + [InlineData("abc")] + [InlineData("12.34.56")] + [InlineData("--5")] + public void InvalidFormat_ReturnsErr(string raw) + { + var result = Input.ParseDecimal(raw); + Assert.False(result.IsOk); + Assert.Contains(raw, result.Error!); + } + } +} diff --git a/tests/HotelTimeCalculator.Tests/ResultTests.cs b/tests/HotelTimeCalculator.Tests/ResultTests.cs new file mode 100644 index 0000000..c3f385e --- /dev/null +++ b/tests/HotelTimeCalculator.Tests/ResultTests.cs @@ -0,0 +1,20 @@ +namespace HotelTimeCalculator.Tests +{ + public class ResultTests + { + [Fact] + public void Ok_SetsIsOkTrue() => Assert.True(Result.Ok(42).IsOk); + + [Fact] + public void Ok_StoresValue() => Assert.Equal(42, Result.Ok(42).Value); + + [Fact] + public void Ok_ErrorIsNull() => Assert.Null(Result.Ok(42).Error); + + [Fact] + public void Err_SetsIsOkFalse() => Assert.False(Result.Err("fail").IsOk); + + [Fact] + public void Err_StoresErrorMessage() => Assert.Equal("fail", Result.Err("fail").Error); + } +} diff --git a/tests/MazeRunner.Tests/CycleDetectingStrategyTests.cs b/tests/MazeRunner.Tests/CycleDetectingStrategyTests.cs new file mode 100644 index 0000000..c16bed0 --- /dev/null +++ b/tests/MazeRunner.Tests/CycleDetectingStrategyTests.cs @@ -0,0 +1,69 @@ +namespace MazeRunner.Tests +{ + public class CycleDetectingStrategyTests + { + private static MazeGrid CreateLoopMaze() + { + // 2x2 open square — left/right hand will loop + var cells = new Cell[4, 4] + { + { Cell.Wall, Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Wall, Cell.Start, Cell.Path, Cell.Wall }, + { Cell.Wall, Cell.Path, Cell.Finish, Cell.Wall }, + { Cell.Wall, Cell.Wall, Cell.Wall, Cell.Wall }, + }; + return new MazeGrid(cells, new Position(1, 1), new Position(2, 2)); + } + + [Fact] + public void DetectsCycle_WhenRevisitingState() + { + var maze = CreateLoopMaze(); + var inner = new LeftHandStrategy(); + var detector = new CycleDetectingStrategy(inner); + + var pos = maze.Start; + var facing = Direction.Down; + + for (var i = 0; i < 20; i++) + { + (pos, facing) = detector.NextStep(maze, pos, facing); + if (detector.IsCycleDetected) + break; + } + + // In this maze the left-hand should reach finish, but if somehow it cycles + // the detector catches it. Let's use a scenario that actually cycles. + } + + [Fact] + public void NoMovement_DetectedAsStuck() + { + // Strategy that never moves + var noMoveStrategy = new StayInPlaceStrategy(); + var detector = new CycleDetectingStrategy(noMoveStrategy); + + var pos = new Position(1, 1); + var facing = Direction.Up; + + for (var i = 0; i < 5; i++) + (pos, facing) = detector.NextStep(null!, pos, facing); + + Assert.True(detector.IsCycleDetected); + Assert.Equal("no movement", detector.StuckReason); + } + + // Helper strategy that always stays in place + private class StayInPlaceStrategy : IDwarfStrategy + { + public bool CanSolve => true; + public bool DetectCycles => true; + + public (Position, Direction) NextStep( + MazeGrid maze, + Position current, + Direction facing + ) => (current, facing); + } + } +} diff --git a/tests/MazeRunner.Tests/DwarfTests.cs b/tests/MazeRunner.Tests/DwarfTests.cs new file mode 100644 index 0000000..3b1a519 --- /dev/null +++ b/tests/MazeRunner.Tests/DwarfTests.cs @@ -0,0 +1,98 @@ +namespace MazeRunner.Tests +{ + public class DwarfTests + { + private static MazeGrid CreateCorridorMaze() + { + var cells = new Cell[3, 4] + { + { Cell.Wall, Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Start, Cell.Path, Cell.Path, Cell.Finish }, + { Cell.Wall, Cell.Wall, Cell.Wall, Cell.Wall }, + }; + return new MazeGrid(cells, new Position(1, 0), new Position(1, 3)); + } + + [Fact] + public void Dwarf_ReachesFinish_WithPathFinder() + { + var maze = CreateCorridorMaze(); + var strategy = new PathFinderStrategy(maze); + var dwarf = new Dwarf( + "Test", + 'T', + ConsoleColor.White, + maze.Start, + Direction.Right, + strategy + ); + + while (!dwarf.HasFinished && dwarf.StepsTaken < 100) + dwarf.Step(maze); + + Assert.True(dwarf.HasFinished); + Assert.Equal(3, dwarf.StepsTaken); + } + + [Fact] + public void Dwarf_MarksStuck_WhenNoPath() + { + var cells = new Cell[3, 3] + { + { Cell.Start, Cell.Wall, Cell.Finish }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(0, 0), new Position(0, 2)); + var strategy = new PathFinderStrategy(maze); + var dwarf = new Dwarf( + "Stuck", + 'S', + ConsoleColor.Red, + maze.Start, + Direction.Right, + strategy + ); + + dwarf.Step(maze); + + Assert.True(dwarf.IsStuck); + Assert.Equal("no path found", dwarf.StuckReason); + } + + [Fact] + public void Dwarf_DoesNotStep_AfterFinished() + { + var maze = CreateCorridorMaze(); + var strategy = new PathFinderStrategy(maze); + var dwarf = new Dwarf( + "Test", + 'T', + ConsoleColor.White, + maze.Start, + Direction.Right, + strategy + ); + + while (!dwarf.HasFinished) + dwarf.Step(maze); + + var stepsAtFinish = dwarf.StepsTaken; + dwarf.Step(maze); + Assert.Equal(stepsAtFinish, dwarf.StepsTaken); + } + + [Fact] + public void DwarfFactory_CreatesWithCorrectSpec() + { + var maze = CreateCorridorMaze(); + var rng = new Random(0); + var dwarf = DwarfFactory.Create(DwarfSpecs.PathFinder, maze, rng); + + Assert.Equal(DwarfSpecs.PathFinder.Name, dwarf.Name); + Assert.Equal(DwarfSpecs.PathFinder.Symbol, dwarf.Symbol); + Assert.Equal(DwarfSpecs.PathFinder.Color, dwarf.Color); + Assert.Equal(maze.Start, dwarf.Position); + } + } +} diff --git a/tests/MazeRunner.Tests/MazeGridTests.cs b/tests/MazeRunner.Tests/MazeGridTests.cs new file mode 100644 index 0000000..bdf672e --- /dev/null +++ b/tests/MazeRunner.Tests/MazeGridTests.cs @@ -0,0 +1,89 @@ +namespace MazeRunner.Tests +{ + public class MazeGridTests + { + private static MazeGrid CreateSimpleMaze() + { + // 3x3: S on (0,0), F on (2,2), walls on edges + var cells = new Cell[3, 3] + { + { Cell.Start, Cell.Path, Cell.Wall }, + { Cell.Wall, Cell.Path, Cell.Wall }, + { Cell.Wall, Cell.Path, Cell.Finish }, + }; + return new MazeGrid(cells, new Position(0, 0), new Position(2, 2)); + } + + [Fact] + public void Dimensions_MatchCellArray() + { + var maze = CreateSimpleMaze(); + Assert.Equal(3, maze.Height); + Assert.Equal(3, maze.Width); + } + + [Fact] + public void Indexer_ReturnsCorrectCell() + { + var maze = CreateSimpleMaze(); + Assert.Equal(Cell.Start, maze[new Position(0, 0)]); + Assert.Equal(Cell.Wall, maze[new Position(0, 2)]); + Assert.Equal(Cell.Finish, maze[new Position(2, 2)]); + } + + [Fact] + public void Indexer_OutOfBounds_Throws() + { + var maze = CreateSimpleMaze(); + Assert.Throws(() => maze[new Position(-1, 0)]); + Assert.Throws(() => maze[new Position(3, 0)]); + } + + [Fact] + public void IsInBounds_ValidPositions_ReturnsTrue() + { + var maze = CreateSimpleMaze(); + Assert.True(maze.IsInBounds(new Position(0, 0))); + Assert.True(maze.IsInBounds(new Position(2, 2))); + } + + [Fact] + public void IsInBounds_InvalidPositions_ReturnsFalse() + { + var maze = CreateSimpleMaze(); + Assert.False(maze.IsInBounds(new Position(-1, 0))); + Assert.False(maze.IsInBounds(new Position(0, -1))); + Assert.False(maze.IsInBounds(new Position(3, 0))); + Assert.False(maze.IsInBounds(new Position(0, 3))); + } + + [Fact] + public void IsPathClear_Path_ReturnsTrue() + { + var maze = CreateSimpleMaze(); + Assert.True(maze.IsPathClear(new Position(0, 1))); + } + + [Fact] + public void IsPathClear_Wall_ReturnsFalse() + { + var maze = CreateSimpleMaze(); + Assert.False(maze.IsPathClear(new Position(0, 2))); + } + + [Fact] + public void IsPathClear_OutOfBounds_ReturnsFalse() + { + var maze = CreateSimpleMaze(); + Assert.False(maze.IsPathClear(new Position(-1, 0))); + } + + [Theory] + [InlineData(0, 0, 'S')] + [InlineData(0, 2, '#')] + [InlineData(2, 2, 'F')] + [InlineData(0, 1, ' ')] + public void CellChar_ReturnsExpected(int row, int col, char expected) => + Assert.Equal(expected, CreateSimpleMaze().CellChar(new Position(row, col))); + } +} diff --git a/tests/MazeRunner.Tests/MazeLoaderTests.cs b/tests/MazeRunner.Tests/MazeLoaderTests.cs new file mode 100644 index 0000000..4b2f8fc --- /dev/null +++ b/tests/MazeRunner.Tests/MazeLoaderTests.cs @@ -0,0 +1,74 @@ +namespace MazeRunner.Tests +{ + public class MazeLoaderTests + { + [Fact] + public void Load_NonExistentFile_ReturnsError() + { + var result = MazeLoader.Load("nonexistent.dat"); + Assert.False(result.IsOk); + Assert.Contains("not found", result.Error!); + } + + [Fact] + public void Load_ValidMaze_ReturnsOk() + { + var path = WriteTempMaze("#S#\n" + "# #\n" + "#F#\n"); + + try + { + var result = MazeLoader.Load(path); + Assert.True(result.IsOk); + Assert.Equal(3, result.Value.Height); + Assert.Equal(3, result.Value.Width); + Assert.Equal(new Position(0, 1), result.Value.Start); + Assert.Equal(new Position(2, 1), result.Value.Finish); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Load_NoStart_ReturnsError() + { + var path = WriteTempMaze("###\n" + "# #\n" + "#F#\n"); + + try + { + var result = MazeLoader.Load(path); + Assert.False(result.IsOk); + Assert.Contains("start", result.Error!, System.StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Load_NoFinish_ReturnsError() + { + var path = WriteTempMaze("#S#\n" + "# #\n" + "###\n"); + + try + { + var result = MazeLoader.Load(path); + Assert.False(result.IsOk); + Assert.Contains("finish", result.Error!, System.StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } + + private static string WriteTempMaze(string content) + { + var path = Path.GetTempFileName(); + File.WriteAllText(path, content); + return path; + } + } +} diff --git a/tests/MazeRunner.Tests/MazeRendererTests.cs b/tests/MazeRunner.Tests/MazeRendererTests.cs new file mode 100644 index 0000000..e383dfe --- /dev/null +++ b/tests/MazeRunner.Tests/MazeRendererTests.cs @@ -0,0 +1,105 @@ +namespace MazeRunner.Tests +{ + public class MazeRendererTests + { + private static MazeGrid CreateSmallMaze() + { + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Start, Cell.Path, Cell.Finish }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + return new MazeGrid(cells, new Position(1, 0), new Position(1, 2)); + } + + [Fact] + public void DrawFull_RendersAllCells() + { + var maze = CreateSmallMaze(); + var display = new FakeConsoleDisplay(); + var renderer = new MazeRenderer(maze, display); + + renderer.DrawFull(); + + // 3x3 = 9 cells, each writes one char + Assert.Equal(9, display.CharsWritten.Count); + Assert.False(display.CursorWasVisible); + Assert.True(display.WasCleared); + } + + [Fact] + public void DrawDwarf_WritesSymbolAtPosition() + { + var maze = CreateSmallMaze(); + var display = new FakeConsoleDisplay(); + var renderer = new MazeRenderer(maze, display); + + renderer.DrawDwarf(new Position(1, 1), 'D', ConsoleColor.Yellow); + + Assert.Contains(('D', 1, 1), display.CharsWritten); + Assert.Contains(ConsoleColor.Yellow, display.ColorsSet); + } + + [Fact] + public void ClearDwarf_RestoresOriginalCell() + { + var maze = CreateSmallMaze(); + var display = new FakeConsoleDisplay(); + var renderer = new MazeRenderer(maze, display); + + renderer.ClearDwarf(new Position(1, 1)); + + // Path cell should be rendered (space char) + Assert.True(display.CharsWritten.Count > 0); + } + + [Fact] + public void WriteStatus_WritesMessageBelowMaze() + { + var maze = CreateSmallMaze(); + var display = new FakeConsoleDisplay(); + var renderer = new MazeRenderer(maze, display); + + renderer.WriteStatus(1, "test status", ConsoleColor.Cyan); + + Assert.Contains("test status", display.StringsWritten[0]); + Assert.Contains(ConsoleColor.Cyan, display.ColorsSet); + } + } + + internal class FakeConsoleDisplay : IConsoleDisplay + { + public int WindowWidth => 80; + public int WindowHeight => 25; + public bool CursorWasVisible { get; private set; } = true; + public bool WasCleared { get; private set; } + public List<(char ch, int left, int top)> CharsWritten { get; } = new(); + public List StringsWritten { get; } = new(); + public List ColorsSet { get; } = new(); + + private int _curLeft; + private int _curTop; + + public bool CursorVisible + { + set => CursorWasVisible = value; + } + + public void SetCursorPosition(int left, int top) + { + _curLeft = left; + _curTop = top; + } + + public void SetColor(ConsoleColor color) => ColorsSet.Add(color); + + public void ResetColor() { } + + public void Write(char ch) => CharsWritten.Add((ch, _curLeft, _curTop)); + + public void Write(string text) => StringsWritten.Add(text); + + public void Clear() => WasCleared = true; + } +} diff --git a/tests/MazeRunner.Tests/MazeRunner.Tests.csproj b/tests/MazeRunner.Tests/MazeRunner.Tests.csproj new file mode 100644 index 0000000..25ac3ee --- /dev/null +++ b/tests/MazeRunner.Tests/MazeRunner.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/tests/MazeRunner.Tests/PositionAndDirectionTests.cs b/tests/MazeRunner.Tests/PositionAndDirectionTests.cs new file mode 100644 index 0000000..9323a94 --- /dev/null +++ b/tests/MazeRunner.Tests/PositionAndDirectionTests.cs @@ -0,0 +1,72 @@ +namespace MazeRunner.Tests +{ + public class PositionTests + { + [Fact] + public void Move_Up() => + Assert.Equal(new Position(0, 1), new Position(1, 1).Move(Direction.Up)); + + [Fact] + public void Move_Down() => + Assert.Equal(new Position(2, 1), new Position(1, 1).Move(Direction.Down)); + + [Fact] + public void Move_Left() => + Assert.Equal(new Position(1, 0), new Position(1, 1).Move(Direction.Left)); + + [Fact] + public void Move_Right() => + Assert.Equal(new Position(1, 2), new Position(1, 1).Move(Direction.Right)); + } + + public class DirectionTests + { + [Fact] + public void TurnLeft_Up_ReturnsLeft() => + Assert.Equal(Direction.Left, Direction.Up.TurnLeft()); + + [Fact] + public void TurnLeft_Left_ReturnsDown() => + Assert.Equal(Direction.Down, Direction.Left.TurnLeft()); + + [Fact] + public void TurnLeft_Down_ReturnsRight() => + Assert.Equal(Direction.Right, Direction.Down.TurnLeft()); + + [Fact] + public void TurnLeft_Right_ReturnsUp() => + Assert.Equal(Direction.Up, Direction.Right.TurnLeft()); + + [Fact] + public void TurnRight_Up_ReturnsRight() => + Assert.Equal(Direction.Right, Direction.Up.TurnRight()); + + [Fact] + public void TurnRight_Right_ReturnsDown() => + Assert.Equal(Direction.Down, Direction.Right.TurnRight()); + + [Fact] + public void TurnRight_Down_ReturnsLeft() => + Assert.Equal(Direction.Left, Direction.Down.TurnRight()); + + [Fact] + public void TurnRight_Left_ReturnsUp() => + Assert.Equal(Direction.Up, Direction.Left.TurnRight()); + + [Fact] + public void Reverse_Up_ReturnsDown() => + Assert.Equal(Direction.Down, Direction.Up.Reverse()); + + [Fact] + public void Reverse_Down_ReturnsUp() => + Assert.Equal(Direction.Up, Direction.Down.Reverse()); + + [Fact] + public void Reverse_Left_ReturnsRight() => + Assert.Equal(Direction.Right, Direction.Left.Reverse()); + + [Fact] + public void Reverse_Right_ReturnsLeft() => + Assert.Equal(Direction.Left, Direction.Right.Reverse()); + } +} diff --git a/tests/MazeRunner.Tests/StrategyTests.cs b/tests/MazeRunner.Tests/StrategyTests.cs new file mode 100644 index 0000000..62c139c --- /dev/null +++ b/tests/MazeRunner.Tests/StrategyTests.cs @@ -0,0 +1,312 @@ +namespace MazeRunner.Tests +{ + public class StrategyTests + { + // Corridor maze: S . . F (single row, walls above and below) + // # # # # + private static MazeGrid CreateCorridorMaze() + { + var cells = new Cell[3, 4] + { + { Cell.Wall, Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Start, Cell.Path, Cell.Path, Cell.Finish }, + { Cell.Wall, Cell.Wall, Cell.Wall, Cell.Wall }, + }; + return new MazeGrid(cells, new Position(1, 0), new Position(1, 3)); + } + + [Fact] + public void LeftHand_MovesForwardInCorridor() + { + var maze = CreateCorridorMaze(); + var strategy = new LeftHandStrategy(); + var (pos, dir) = strategy.NextStep(maze, new Position(1, 0), Direction.Right); + Assert.Equal(new Position(1, 1), pos); + Assert.Equal(Direction.Right, dir); + } + + [Fact] + public void RightHand_MovesForwardInCorridor() + { + var maze = CreateCorridorMaze(); + var strategy = new RightHandStrategy(); + var (pos, dir) = strategy.NextStep(maze, new Position(1, 0), Direction.Right); + Assert.Equal(new Position(1, 1), pos); + Assert.Equal(Direction.Right, dir); + } + + [Fact] + public void LeftHand_TurnsLeftWhenOpen() + { + // T-junction: can go left from facing right + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Path, Cell.Wall }, + { Cell.Start, Cell.Path, Cell.Path }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(1, 0), new Position(0, 1)); + var strategy = new LeftHandStrategy(); + + // facing right at (1,1), left = up, up is (0,1) which is open + var (pos, dir) = strategy.NextStep(maze, new Position(1, 1), Direction.Right); + Assert.Equal(new Position(0, 1), pos); + Assert.Equal(Direction.Up, dir); + } + + [Fact] + public void RightHand_TurnsRightWhenOpen() + { + // T-junction: can go right (down) from facing right + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Start, Cell.Path, Cell.Path }, + { Cell.Wall, Cell.Path, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(1, 0), new Position(2, 1)); + var strategy = new RightHandStrategy(); + + // facing right at (1,1), right = down, down is (2,1) which is open + var (pos, dir) = strategy.NextStep(maze, new Position(1, 1), Direction.Right); + Assert.Equal(new Position(2, 1), pos); + Assert.Equal(Direction.Down, dir); + } + + [Fact] + public void PathFinder_FindsShortestPath() + { + var maze = CreateCorridorMaze(); + var strategy = new PathFinderStrategy(maze); + + Assert.True(strategy.CanSolve); + + var pos = maze.Start; + var facing = Direction.Right; + var steps = 0; + + while (pos != maze.Finish && steps < 100) + { + (pos, facing) = strategy.NextStep(maze, pos, facing); + steps++; + } + + Assert.Equal(maze.Finish, pos); + Assert.Equal(3, steps); // S -> (1,1) -> (1,2) -> F + } + + [Fact] + public void PathFinder_NoPath_CanSolveIsFalse() + { + // Start and finish completely walled off + var cells = new Cell[3, 3] + { + { Cell.Start, Cell.Wall, Cell.Finish }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(0, 0), new Position(0, 2)); + var strategy = new PathFinderStrategy(maze); + + Assert.False(strategy.CanSolve); + } + + [Fact] + public void Teleport_EventuallyReachesFinish() + { + var maze = CreateCorridorMaze(); + var rng = new System.Random(42); + var strategy = new TeleportStrategy(rng, minSteps: 1, maxSteps: 5); + + var pos = maze.Start; + var facing = Direction.Right; + + for (var i = 0; i < 100 && pos != maze.Finish; i++) + (pos, facing) = strategy.NextStep(maze, pos, facing); + + Assert.Equal(maze.Finish, pos); + } + + [Fact] + public void LeftHand_AllWalled_StaysInPlace() + { + // Dwarf surrounded by walls on all sides + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Wall, Cell.Start, Cell.Wall }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(1, 1), new Position(1, 1)); + var strategy = new LeftHandStrategy(); + + var (pos, _) = strategy.NextStep(maze, new Position(1, 1), Direction.Up); + Assert.Equal(new Position(1, 1), pos); + } + + [Fact] + public void RightHand_AllWalled_StaysInPlace() + { + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Wall, Cell.Start, Cell.Wall }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(1, 1), new Position(1, 1)); + var strategy = new RightHandStrategy(); + + var (pos, _) = strategy.NextStep(maze, new Position(1, 1), Direction.Up); + Assert.Equal(new Position(1, 1), pos); + } + + [Fact] + public void RightHand_TurnsLeftWhenOnlyLeftOpen() + { + // Only left (up) is open when facing right + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Path, Cell.Wall }, + { Cell.Start, Cell.Path, Cell.Wall }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(1, 0), new Position(0, 1)); + var strategy = new RightHandStrategy(); + + // At (1,1) facing right: right=down(wall), forward=right(wall), left=up(open) + var (pos, dir) = strategy.NextStep(maze, new Position(1, 1), Direction.Right); + Assert.Equal(new Position(0, 1), pos); + Assert.Equal(Direction.Up, dir); + } + + [Fact] + public void RightHand_ReversesWhenOnlyBackOpen() + { + // Dead end: only back is open + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Path, Cell.Path, Cell.Wall }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(1, 0), new Position(1, 0)); + var strategy = new RightHandStrategy(); + + // At (1,1) facing right: right(wall), forward(wall), left(wall), back=left(open) + var (pos, dir) = strategy.NextStep(maze, new Position(1, 1), Direction.Right); + Assert.Equal(new Position(1, 0), pos); + Assert.Equal(Direction.Left, dir); + } + + [Fact] + public void LeftHand_ReversesWhenOnlyBackOpen() + { + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Path, Cell.Path, Cell.Wall }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(1, 0), new Position(1, 0)); + var strategy = new LeftHandStrategy(); + + var (pos, dir) = strategy.NextStep(maze, new Position(1, 1), Direction.Right); + Assert.Equal(new Position(1, 0), pos); + Assert.Equal(Direction.Left, dir); + } + + [Fact] + public void Teleport_ImmediateTeleport() + { + var maze = CreateCorridorMaze(); + // minSteps=1, maxSteps=2 — teleport after 1 step + var rng = new System.Random(0); + var strategy = new TeleportStrategy(rng, minSteps: 1, maxSteps: 2); + + var pos = maze.Start; + var facing = Direction.Right; + + // After at most 2 steps, should teleport + for (var i = 0; i < 2; i++) + (pos, facing) = strategy.NextStep(maze, pos, facing); + + Assert.Equal(maze.Finish, pos); + } + + [Fact] + public void Teleport_TurnsLeftWhenForwardBlocked() + { + // Facing a wall, left is open + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Path, Cell.Wall }, + { Cell.Start, Cell.Path, Cell.Wall }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(1, 0), new Position(0, 1)); + // Large steps so no teleport + var strategy = new TeleportStrategy( + new System.Random(0), + minSteps: 999, + maxSteps: 1000 + ); + + // At (1,1) facing right: forward blocked, left=up is open + var (pos, dir) = strategy.NextStep(maze, new Position(1, 1), Direction.Right); + Assert.Equal(new Position(0, 1), pos); + Assert.Equal(Direction.Up, dir); + } + + [Fact] + public void Teleport_TurnsRightWhenForwardAndLeftBlocked() + { + // Only right (down) is open + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Start, Cell.Path, Cell.Wall }, + { Cell.Wall, Cell.Path, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(1, 0), new Position(2, 1)); + var strategy = new TeleportStrategy( + new System.Random(0), + minSteps: 999, + maxSteps: 1000 + ); + + // At (1,1) facing right: forward blocked, left=up(wall), right=down(open) + var (pos, dir) = strategy.NextStep(maze, new Position(1, 1), Direction.Right); + Assert.Equal(new Position(2, 1), pos); + Assert.Equal(Direction.Down, dir); + } + + [Fact] + public void Teleport_ReversesWhenAllElseBlocked() + { + // Dead end - only back open + var cells = new Cell[3, 3] + { + { Cell.Wall, Cell.Wall, Cell.Wall }, + { Cell.Path, Cell.Path, Cell.Wall }, + { Cell.Wall, Cell.Wall, Cell.Wall }, + }; + var maze = new MazeGrid(cells, new Position(1, 0), new Position(1, 0)); + var strategy = new TeleportStrategy( + new System.Random(0), + minSteps: 999, + maxSteps: 1000 + ); + + var (pos, dir) = strategy.NextStep(maze, new Position(1, 1), Direction.Right); + Assert.Equal(new Position(1, 0), pos); + Assert.Equal(Direction.Left, dir); + } + + [Fact] + public void Teleport_DetectCycles_IsFalse() + { + var strategy = new TeleportStrategy(new System.Random(0)); + Assert.False(strategy.DetectCycles); + } + } +} From 1a8036a2db3e9db8fdad6be8fffdbc2e1711550a Mon Sep 17 00:00:00 2001 From: Vaclav Vracovsky Date: Tue, 7 Apr 2026 10:43:29 +0200 Subject: [PATCH 5/5] Add basic GitHub workflow for CI --- .github/workflows/ci.yml | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..656bf41 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + - run: dotnet restore hoteltime.sln + - run: dotnet build hoteltime.sln --no-restore + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + - run: dotnet tool install --global csharpier + - run: csharpier check . + + test: + runs-on: ubuntu-latest + needs: [build, format] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + - run: dotnet restore hoteltime.sln + - run: dotnet test hoteltime.sln --no-restore --collect:"XPlat Code Coverage" --results-directory ./coverage + - name: Install ReportGenerator + run: dotnet tool install -g dotnet-reportgenerator-globaltool + - name: Generate coverage summary + run: | + reportgenerator \ + -reports:"./coverage/**/coverage.cobertura.xml" \ + -targetdir:"./coverage/summary" \ + -reporttypes:TextSummary + cat ./coverage/summary/Summary.txt + - name: Check coverage threshold + run: | + threshold=$(grep -oP '^COVERAGE_THRESHOLD\s*=\s*\K[0-9]+' Makefile) + line_coverage=$(grep -oP 'Line coverage: \K[0-9.]+' ./coverage/summary/Summary.txt | head -1) + pass=$(echo "$line_coverage >= $threshold" | bc -l) + if [ "$pass" = "1" ]; then + echo "PASS: Line coverage ${line_coverage}% >= ${threshold}%" + else + echo "FAIL: Line coverage ${line_coverage}% < ${threshold}%" + exit 1 + fi