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
90 changes: 90 additions & 0 deletions ImageDescriber.Test/MenuTests.cs
Original file line number Diff line number Diff line change
@@ -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;
}

Check warning on line 17 in ImageDescriber.Test/MenuTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImageDescriber&issues=AaDmuF3lHTUeIeoVafmw&open=AaDmuF3lHTUeIeoVafmw&pullRequest=170

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");

Check warning on line 38 in ImageDescriber.Test/MenuTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImageDescriber&issues=AaDmuF3lHTUeIeoVafmu&open=AaDmuF3lHTUeIeoVafmu&pullRequest=170
StringAssert.Contains(text, "Exit");

Check warning on line 39 in ImageDescriber.Test/MenuTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImageDescriber&issues=AaDmuF3lHTUeIeoVafmv&open=AaDmuF3lHTUeIeoVafmv&pullRequest=170
Assert.IsFalse(text.Contains("1. Menu", StringComparison.Ordinal), "The menu should not list itself.");
}

[TestMethod]
public void PromptMenuRunsTheChosenItemUntilExit()
{
List<string> 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");

Check warning on line 58 in ImageDescriber.Test/MenuTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImageDescriber&issues=AaDmuF3lHTUeIeoVafmx&open=AaDmuF3lHTUeIeoVafmx&pullRequest=170
StringAssert.Contains(output.ToString(), "2. Second");

Check warning on line 59 in ImageDescriber.Test/MenuTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImageDescriber&issues=AaDmuF3lHTUeIeoVafmy&open=AaDmuF3lHTUeIeoVafmy&pullRequest=170
StringAssert.Contains(output.ToString(), "3. Exit");

Check warning on line 60 in ImageDescriber.Test/MenuTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImageDescriber&issues=AaDmuF3lHTUeIeoVafmz&open=AaDmuF3lHTUeIeoVafmz&pullRequest=170
}

[TestMethod]
public void PromptMenuRejectsInvalidChoices()
{
List<string> 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);

Check warning on line 73 in ImageDescriber.Test/MenuTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImageDescriber&issues=AaDmuF3lHTUeIeoVafm0&open=AaDmuF3lHTUeIeoVafm0&pullRequest=170
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.");

Check warning on line 75 in ImageDescriber.Test/MenuTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.HasCount' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImageDescriber&issues=AaDmuF3lHTUeIeoVafm1&open=AaDmuF3lHTUeIeoVafm1&pullRequest=170
}

[TestMethod]
public void PromptMenuStopsAtEndOfInput()
{
List<string> 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));
}
}
82 changes: 70 additions & 12 deletions ImageDescriber/Verbs/Menu.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -16,6 +19,59 @@ namespace ktsu.ImageDescriber.Verbs;
internal sealed class Menu : BaseVerb<Menu>
{
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;

Expand All @@ -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()
Expand All @@ -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<VerbAttribute>()?.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,
};
}
Loading