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
93 changes: 93 additions & 0 deletions ImageDescriber.Test/MenuTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace ktsu.ImageDescriber.Tests;

using ktsu.ImageDescriber.Verbs;
using ktsu.Semantics.Paths;
using ktsu.Semantics.Strings;

[TestClass]
public class MenuTests
Expand Down Expand Up @@ -87,4 +89,95 @@ public void PromptMenuStopsAtEndOfInput()

Assert.AreEqual("Only", string.Join(",", ran));
}

[TestMethod]
public void ExportRunWithoutAnOutputPathAsksForOne()
{
string outputFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.json");
try
{
string text = RunWithConsole(new Export(), $"{outputFile}\n");

Assert.Contains("Enter the output file path", text);
Assert.IsTrue(File.Exists(outputFile));
Assert.Contains("A dog on a beach.", File.ReadAllText(outputFile));
}
finally
{
File.Delete(outputFile);
}
}

[TestMethod]
public void ExportRunWithAnEmptyAnswerAbortsCleanly()
{
string text = RunWithConsole(new Export(), "\n");

Assert.Contains("No path provided. Aborting.", text);
}

[TestMethod]
public void SearchRunWithoutAQueryAsksForOne()
{
string text = RunWithConsole(new Search(), "dog\n");

Assert.Contains("Enter the search query", text);
Assert.Contains("Search results for \"dog\": 1 match(es)", text);
}

[TestMethod]
public void MenuItemsAskAgainOnEveryRun()
{
Action search = Menu.CreateVerbAction(typeof(Search));

string text = RunWithConsole(search, "dog\ncat\n");

Assert.Contains("Search results for \"dog\": 1 match(es)", text);
Assert.Contains("Search results for \"cat\": 0 match(es)", text);
}

private static string RunWithConsole(BaseVerb verb, string input) => RunWithConsole(verb.Run, input);

/// <summary>
/// Runs <paramref name="run"/> as many times as <paramref name="input"/> has lines, against a
/// database holding one description, with the console redirected.
/// </summary>
private static string RunWithConsole(Action run, string input)
{
PersistentState originalSettings = Program.Settings;
TextReader originalIn = Console.In;
TextWriter originalOut = Console.Out;
using StringReader reader = new(input);
using StringWriter output = new();
try
{
Program.Settings = new PersistentState();
Program.Settings.Descriptions[new string('a', 64)] = new ImageDescription
{
Hash = new string('a', 64),
SuggestedFileName = "dog-on-beach.jpg".As<FileName>(),
KnownPaths = [Path.Combine(Path.GetTempPath(), "a.jpg").As<AbsoluteFilePath>()],
Model = "llava".As<OllamaModelName>(),
DescribedAt = new DateTime(2026, 9, 27, 1, 7, 46, DateTimeKind.Utc),
FileSizeBytes = 1,
Description = "A dog on a beach.",
};
Console.SetIn(reader);
Console.SetOut(output);

int runs = input.Count(c => c == '\n');
for (int i = 0; i < runs; i++)
{
run();
}
}
finally
{
Console.SetIn(originalIn);
Console.SetOut(originalOut);
Program.Settings = originalSettings;
}

return output.ToString();
}
}
19 changes: 19 additions & 0 deletions ImageDescriber/Verbs/Export.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ internal sealed class Export : BaseVerb<Export>
[Option('o', "output", Required = true, HelpText = "Output file path (.json or .csv).")]
public string OutputPath { get; set; } = string.Empty;

internal override bool ValidateArgs()
{
// The menu runs verbs without CommandLineParser, which is what enforces Required = true
if (string.IsNullOrWhiteSpace(OutputPath))
{
Console.Write("Enter the output file path (.json or .csv): ");
string? input = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(input))
{
Console.WriteLine("No path provided. Aborting.");
return false;
}

OutputPath = input;
}

return base.ValidateArgs();
}

internal override void Run(Export options)
{
Dictionary<string, ImageDescription> descriptions = Program.Settings.Descriptions;
Expand Down
10 changes: 8 additions & 2 deletions ImageDescriber/Verbs/Menu.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ internal override void Run(Menu options)
}
else
{
RunPromptMenu(Console.In, Console.Out, [.. verbs.Select(verb => (GetMenuText(verb), (Action)(() => CreateVerb(verb).Execute())))]);
RunPromptMenu(Console.In, Console.Out, [.. verbs.Select(verb => (GetMenuText(verb), CreateVerbAction(verb)))]);
}
}

Expand Down Expand Up @@ -109,6 +109,12 @@ private sealed class ActionCommand(Action action) : ICommand
public void Execute() => action();
}

/// <summary>
/// Returns an action that runs a fresh instance of the verb each time, so a path or query
/// entered for one run is asked for again on the next rather than silently reused.
/// </summary>
internal static Action CreateVerbAction(Type verbType) => () => CreateVerb(verbType).Execute();

private static BaseVerb CreateVerb(Type verbType)
{
BaseVerb? verb = Activator.CreateInstance(verbType) as BaseVerb;
Expand All @@ -126,7 +132,7 @@ private static string GetMenuText(Type verbType)
private static LabelMenuItem CreateMenuItem(Type verbType) => new()
{
Text = GetMenuText(verbType),
Command = CreateVerb(verbType),
Command = new ActionCommand(CreateVerbAction(verbType)),
IsEnabled = true,
};
}
19 changes: 19 additions & 0 deletions ImageDescriber/Verbs/Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ internal sealed class Search : BaseVerb<Search>
[Option('q', "query", Required = true, HelpText = "The search query to find in descriptions.")]
public string Query { get; set; } = string.Empty;

internal override bool ValidateArgs()
{
// The menu runs verbs without CommandLineParser, which is what enforces Required = true
if (string.IsNullOrWhiteSpace(Query))
{
Console.Write("Enter the search query: ");
string? input = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(input))
{
Console.WriteLine("No query provided. Aborting.");
return false;
}

Query = input;
}

return base.ValidateArgs();
}

internal override void Run(Search options)
{
if (string.IsNullOrWhiteSpace(options.Query))
Expand Down
Loading