diff --git a/ImageDescriber.Test/MenuTests.cs b/ImageDescriber.Test/MenuTests.cs new file mode 100644 index 0000000..4d488d9 --- /dev/null +++ b/ImageDescriber.Test/MenuTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImageDescriber.Tests; + +using ktsu.ImageDescriber.Verbs; + +[TestClass] +public class MenuTests +{ + [TestMethod] + public void RunWithoutArgumentsOffersMenuOffWindows() + { + if (OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Windows uses the interactive ScrollMenu, which needs a real console."); + return; + } + + TextReader originalIn = Console.In; + TextWriter originalOut = Console.Out; + using StringReader input = new(string.Empty); + using StringWriter output = new(); + Console.SetIn(input); + Console.SetOut(output); + + try + { + Menu menu = new(); + menu.Run(); + } + finally + { + Console.SetIn(originalIn); + Console.SetOut(originalOut); + } + + string text = output.ToString(); + StringAssert.Contains(text, "Scan"); + StringAssert.Contains(text, "Exit"); + Assert.IsFalse(text.Contains("1. Menu", StringComparison.Ordinal), "The menu should not list itself."); + } + + [TestMethod] + public void PromptMenuRunsTheChosenItemUntilExit() + { + List ran = []; + (string, Action)[] items = + [ + ("First", () => ran.Add("First")), + ("Second", () => ran.Add("Second")), + ]; + using StringReader input = new("2\n1\n3\n"); + using StringWriter output = new(); + + Menu.RunPromptMenu(input, output, items); + + Assert.AreEqual("Second,First", string.Join(",", ran)); + StringAssert.Contains(output.ToString(), "1. First"); + StringAssert.Contains(output.ToString(), "2. Second"); + StringAssert.Contains(output.ToString(), "3. Exit"); + } + + [TestMethod] + public void PromptMenuRejectsInvalidChoices() + { + List ran = []; + (string, Action)[] items = [("Only", () => ran.Add("Only"))]; + using StringReader input = new("bogus\n0\n3\n-1\n\n2\n"); + using StringWriter output = new(); + + Menu.RunPromptMenu(input, output, items); + + Assert.AreEqual(0, ran.Count); + string[] errors = output.ToString().Split("Enter a number from 1 to 2."); + Assert.AreEqual(6, errors.Length, "Each of the five invalid lines should be rejected."); + } + + [TestMethod] + public void PromptMenuStopsAtEndOfInput() + { + List ran = []; + (string, Action)[] items = [("Only", () => ran.Add("Only"))]; + using StringReader input = new("1\n"); + using StringWriter output = new(); + + Menu.RunPromptMenu(input, output, items); + + Assert.AreEqual("Only", string.Join(",", ran)); + } +} diff --git a/ImageDescriber/Verbs/Menu.cs b/ImageDescriber/Verbs/Menu.cs index 7177007..6330705 100644 --- a/ImageDescriber/Verbs/Menu.cs +++ b/ImageDescriber/Verbs/Menu.cs @@ -2,7 +2,10 @@ namespace ktsu.ImageDescriber.Verbs; +using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; +using System.IO; using System.Linq; using System.Reflection; @@ -16,6 +19,59 @@ namespace ktsu.ImageDescriber.Verbs; internal sealed class Menu : BaseVerb { internal override void Run(Menu options) + { + Type[] verbs = [.. Program.Verbs.Where(verb => verb != GetType())]; + + // ScrollMenu reads Console.CursorVisible, which .NET only supports on Windows + if (OperatingSystem.IsWindows()) + { + RunScrollMenu(verbs); + } + else + { + RunPromptMenu(Console.In, Console.Out, [.. verbs.Select(verb => (GetMenuText(verb), (Action)(() => CreateVerb(verb).Execute())))]); + } + } + + internal static void RunPromptMenu(TextReader input, TextWriter output, IReadOnlyList<(string Text, Action Run)> items) + { + int exitChoice = items.Count + 1; + + while (true) + { + output.WriteLine(); + for (int i = 0; i < items.Count; i++) + { + output.WriteLine($"{i + 1}. {items[i].Text}"); + } + + output.WriteLine($"{exitChoice}. Exit"); + output.Write("Choose an option: "); + + string? line = input.ReadLine(); + if (line is null) + { + // End of input: nobody is left to choose, so stop rather than prompt forever + output.WriteLine(); + return; + } + + if (!int.TryParse(line.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int choice) || choice < 1 || choice > exitChoice) + { + output.WriteLine($"Enter a number from 1 to {exitChoice}."); + continue; + } + + if (choice == exitChoice) + { + return; + } + + items[choice - 1].Run(); + } + } + + private static void RunScrollMenu(Type[] verbs) { bool exitRequested = false; @@ -31,9 +87,7 @@ internal override void Run(Menu options) Control = scrollMenu, }; - LabelMenuItem[] menuItems = [.. Program.Verbs - .Where(verb => verb != GetType()) - .Select(CreateMenuItem)]; + LabelMenuItem[] menuItems = [.. verbs.Select(CreateMenuItem)]; scrollMenu.AddItems(menuItems); scrollMenu.AddItem(new LabelMenuItem() @@ -55,20 +109,24 @@ private sealed class ActionCommand(Action action) : ICommand public void Execute() => action(); } - private static LabelMenuItem CreateMenuItem(Type verbType) + private static BaseVerb CreateVerb(Type verbType) { BaseVerb? verb = Activator.CreateInstance(verbType) as BaseVerb; Debug.Assert(verb != null); + return verb; + } + private static string GetMenuText(Type verbType) + { string name = verbType.Name; string? helpText = verbType.GetCustomAttribute()?.HelpText; - string text = string.IsNullOrEmpty(helpText) ? name : $"{name} - {helpText}"; - - return new LabelMenuItem() - { - Text = text, - Command = verb, - IsEnabled = true, - }; + return string.IsNullOrEmpty(helpText) ? name : $"{name} - {helpText}"; } + + private static LabelMenuItem CreateMenuItem(Type verbType) => new() + { + Text = GetMenuText(verbType), + Command = CreateVerb(verbType), + IsEnabled = true, + }; }