diff --git a/FileDeduplicator.Test/MenuTests.cs b/FileDeduplicator.Test/MenuTests.cs new file mode 100644 index 0000000..0de466c --- /dev/null +++ b/FileDeduplicator.Test/MenuTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FileDeduplicator.Test; + +using DustInTheWind.ConsoleTools.Controls; +using DustInTheWind.ConsoleTools.Controls.Menus; + +using ktsu.FileDeduplicator.Verbs; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests the interactive menu that runs when no verb is given. +/// +/// +/// Input is always redirected here, so these drive the numbered prompt. That prompt is also the +/// menu on Linux and macOS, where the scrolling menu crashed reading Console.CursorVisible +/// (ktsu-dev/FileDeduplicator#161). +/// +[TestClass] +[DoNotParallelize] +public sealed class MenuTests +{ + [TestMethod] + public void MenuListsTheVerbsAndExitsOnZero() + { + string output = ConsoleCapture.Run(new Menu(), "0\n", out int exitCode); + + Assert.AreEqual(0, exitCode, output); + Assert.Contains("Scan - ", output); + Assert.Contains("Deduplicate - ", output); + Assert.Contains("0. Exit", output); + Assert.DoesNotContain("Menu", output, "The menu offered itself."); + } + + [TestMethod] + public void MenuExitsAtTheEndOfInput() + { + _ = ConsoleCapture.Run(new Menu(), string.Empty, out int exitCode); + + Assert.AreEqual(0, exitCode); + } + + [TestMethod] + public void MenuRunsTheChosenVerbAndOffersTheMenuAgain() + { + // Arrange + using TempTree tree = new(); + _ = tree.Write("a.txt", "alpha"); + _ = tree.Write("aa.txt", "alpha"); + Type[] menuVerbs = [.. Program.Verbs.Where(t => t != typeof(Menu))]; + int scan = Array.IndexOf(menuVerbs, typeof(Scan)) + 1; + Assert.IsGreaterThan(0, scan, "Scan is not on the menu."); + + // Act + string output = ConsoleCapture.Run(new Menu(), $"{scan}\n{tree.Root.WeakString}\n0\n", out int exitCode); + + // Assert + Assert.AreEqual(0, exitCode, output); + Assert.Contains("Found 1 group(s) of duplicate files", output); + Assert.AreEqual(2, output.Split("0. Exit").Length - 1, "The menu was not offered again after the verb ran."); + } + + [TestMethod] + public void MenuRejectsAChoiceThatIsNotOnIt() + { + string output = ConsoleCapture.Run(new Menu(), "42\n0\n", out int exitCode); + + Assert.AreEqual(0, exitCode, output); + Assert.Contains("Not an option: 42", output); + } + + /// + /// The scrolling menu used in an interactive Windows console has an Exit item whose command ends + /// the display loop, so it can be left without Ctrl+C. + /// + [TestMethod] + public void ScrollMenuHasAnExitItemThatClosesIt() + { + // Arrange + Menu menu = new(); + ControlRepeater repeater = menu.BuildScrollMenu(out Menu.ExitCommand exit); + + // Act + bool requestedBefore = exit.Requested; + exit.Execute(); + + // Assert + Assert.IsInstanceOfType(repeater.Control); + Assert.IsTrue(exit.IsActive); + Assert.IsFalse(requestedBefore); + Assert.IsTrue(exit.Requested); + } +} diff --git a/FileDeduplicator/Verbs/Menu.cs b/FileDeduplicator/Verbs/Menu.cs index 4fdde2a..246af7d 100644 --- a/FileDeduplicator/Verbs/Menu.cs +++ b/FileDeduplicator/Verbs/Menu.cs @@ -18,6 +18,37 @@ internal sealed class Menu : BaseVerb internal override bool ScansADirectory => false; internal override void Run(Menu options) + { + // ScrollMenu reads Console.CursorVisible, whose getter throws PlatformNotSupportedException on + // every platform but Windows, and it needs a real keyboard to drive. Elsewhere, and whenever + // input is piped in, the menu is a numbered prompt read a line at a time. + if (OperatingSystem.IsWindows() && !Console.IsInputRedirected) + { + RunScrollMenu(); + } + else + { + RunNumberedMenu(); + } + } + + private Type[] MenuVerbs => [.. Program.Verbs.Where(verb => verb != GetType())]; + + private void RunScrollMenu() + { + ControlRepeater menuRepeater = BuildScrollMenu(out ExitCommand exit); + while (!exit.Requested) + { + menuRepeater.Display(); + } + } + + /// + /// Builds the scrolling menu: one item per verb, then Exit. + /// + /// The Exit item's command, which says when the menu has been left. + /// The repeater that displays the menu until Exit is chosen. + internal ControlRepeater BuildScrollMenu(out ExitCommand exit) { ScrollMenu scrollMenu = new() { @@ -31,32 +62,84 @@ internal override void Run(Menu options) Control = scrollMenu, }; - LabelMenuItem[] menuItems = [.. Program.Verbs - .Where(verb => verb != GetType()) - .Select(CreateMenuItem)]; + exit = new(menuRepeater); + LabelMenuItem[] menuItems = [.. MenuVerbs.Select(CreateMenuItem)]; scrollMenu.AddItems(menuItems); + scrollMenu.AddItems([new LabelMenuItem() { Text = "Exit", Command = exit, IsEnabled = true }]); + + return menuRepeater; + } + + private void RunNumberedMenu() + { + BaseVerb[] verbs = [.. MenuVerbs.Select(CreateVerb)]; + string[] texts = [.. MenuVerbs.Select(DescribeVerb)]; while (true) { - menuRepeater.Display(); + Console.WriteLine(); + for (int i = 0; i < verbs.Length; i++) + { + Console.WriteLine($" {i + 1}. {texts[i]}"); + } + + Console.WriteLine(" 0. Exit"); + Console.Write("Choose an option: "); + + // End of input is an exit too, so a piped script cannot leave the menu waiting forever. + string? input = Console.ReadLine()?.Trim(); + if (input is null or "0") + { + return; + } + + if (int.TryParse(input, out int choice) && choice >= 1 && choice <= verbs.Length) + { + verbs[choice - 1].Execute(); + } + else + { + Console.WriteLine($"Not an option: {input}"); + } } } - 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 DescribeVerb(Type verbType) + { string name = verbType.Name; string? helpText = verbType.GetCustomAttribute()?.HelpText; - string text = string.IsNullOrEmpty(helpText) ? name : $"{name} - {helpText}"; + return string.IsNullOrEmpty(helpText) ? name : $"{name} - {helpText}"; + } + + private static LabelMenuItem CreateMenuItem(Type verbType) => new() + { + Text = DescribeVerb(verbType), + Command = CreateVerb(verbType), + IsEnabled = true, + }; + + /// + /// The menu item that leaves the menu, which otherwise could only be left with Ctrl+C. + /// + /// The repeater to stop, which otherwise redisplays the menu indefinitely. + internal sealed class ExitCommand(ControlRepeater repeater) : ICommand + { + internal bool Requested { get; private set; } + + public bool IsActive => true; - return new LabelMenuItem() + public void Execute() { - Text = text, - Command = verb, - IsEnabled = true, - }; + Requested = true; + repeater.RequestClose(); + } } } diff --git a/README.md b/README.md index 5168972..dde47bc 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Run without arguments to launch the interactive menu: FileDeduplicator ``` +On Windows the menu is navigated with the arrow keys. On Linux and macOS, and whenever input is piped +in, it is a numbered list: type the number of a command, or `0` to exit. + ### Commands ```sh