Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions FileDeduplicator.Test/MenuTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests the interactive menu that runs when no verb is given.
/// </summary>
/// <remarks>
/// 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 <c>Console.CursorVisible</c>
/// (ktsu-dev/FileDeduplicator#161).
/// </remarks>
[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);
}

/// <summary>
/// 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.
/// </summary>
[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<ScrollMenu>(repeater.Control);
Assert.IsTrue(exit.IsActive);
Assert.IsFalse(requestedBefore);
Assert.IsTrue(exit.Requested);
}
}
105 changes: 94 additions & 11 deletions FileDeduplicator/Verbs/Menu.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,37 @@ internal sealed class Menu : BaseVerb<Menu>
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();
}
}

/// <summary>
/// Builds the scrolling menu: one item per verb, then Exit.
/// </summary>
/// <param name="exit">The Exit item's command, which says when the menu has been left.</param>
/// <returns>The repeater that displays the menu until Exit is chosen.</returns>
internal ControlRepeater BuildScrollMenu(out ExitCommand exit)
{
ScrollMenu scrollMenu = new()
{
Expand All @@ -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<VerbAttribute>()?.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,
};

/// <summary>
/// The menu item that leaves the menu, which otherwise could only be left with Ctrl+C.
/// </summary>
/// <param name="repeater">The repeater to stop, which otherwise redisplays the menu indefinitely.</param>
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();
}
}
}
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading