diff --git a/ImageDescriber.Test/MenuTests.cs b/ImageDescriber.Test/MenuTests.cs index 4d488d9..1242090 100644 --- a/ImageDescriber.Test/MenuTests.cs +++ b/ImageDescriber.Test/MenuTests.cs @@ -3,6 +3,8 @@ namespace ktsu.ImageDescriber.Tests; using ktsu.ImageDescriber.Verbs; +using ktsu.Semantics.Paths; +using ktsu.Semantics.Strings; [TestClass] public class MenuTests @@ -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); + + /// + /// Runs as many times as has lines, against a + /// database holding one description, with the console redirected. + /// + 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(), + KnownPaths = [Path.Combine(Path.GetTempPath(), "a.jpg").As()], + Model = "llava".As(), + 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(); + } } diff --git a/ImageDescriber/Verbs/Export.cs b/ImageDescriber/Verbs/Export.cs index f20a7e2..2d80f8d 100644 --- a/ImageDescriber/Verbs/Export.cs +++ b/ImageDescriber/Verbs/Export.cs @@ -29,6 +29,25 @@ internal sealed class Export : BaseVerb [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 descriptions = Program.Settings.Descriptions; diff --git a/ImageDescriber/Verbs/Menu.cs b/ImageDescriber/Verbs/Menu.cs index 6330705..f9673d9 100644 --- a/ImageDescriber/Verbs/Menu.cs +++ b/ImageDescriber/Verbs/Menu.cs @@ -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)))]); } } @@ -109,6 +109,12 @@ private sealed class ActionCommand(Action action) : ICommand public void Execute() => action(); } + /// + /// 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. + /// + internal static Action CreateVerbAction(Type verbType) => () => CreateVerb(verbType).Execute(); + private static BaseVerb CreateVerb(Type verbType) { BaseVerb? verb = Activator.CreateInstance(verbType) as BaseVerb; @@ -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, }; } diff --git a/ImageDescriber/Verbs/Search.cs b/ImageDescriber/Verbs/Search.cs index 3a6c66c..8071cc2 100644 --- a/ImageDescriber/Verbs/Search.cs +++ b/ImageDescriber/Verbs/Search.cs @@ -15,6 +15,25 @@ internal sealed class Search : BaseVerb [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))