From 74f88a8d4b4302fb730c108a342482248e661874 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:27:31 +0000 Subject: [PATCH 1/2] Prompt for Export's path and Search's query when run from the menu [patch] The menu runs verbs without CommandLineParser, so Export's required -o and Search's required -q were never asked for: Export crashed on Path.GetFullPath("") and Search always reported an empty query. Both now prompt in ValidateArgs, as Scan and Import already do, and abort cleanly on an empty answer. The Windows scroll menu also built each verb once and reused it for the whole session, so a second Import re-imported the first file without asking. Both menus now run a fresh verb instance per selection. Fixes ktsu-dev/ImageDescriber#156 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kj95GdQsZpSCcND7LqacT1 --- ImageDescriber.Test/MenuTests.cs | 93 ++++++++++++++++++++++++++++++++ ImageDescriber/Verbs/Export.cs | 19 +++++++ ImageDescriber/Verbs/Menu.cs | 10 +++- ImageDescriber/Verbs/Search.cs | 19 +++++++ 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/ImageDescriber.Test/MenuTests.cs b/ImageDescriber.Test/MenuTests.cs index 4d488d9..7abfea0 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"); + + StringAssert.Contains(text, "Enter the output file path"); + Assert.IsTrue(File.Exists(outputFile)); + StringAssert.Contains(File.ReadAllText(outputFile), "A dog on a beach."); + } + finally + { + File.Delete(outputFile); + } + } + + [TestMethod] + public void ExportRunWithAnEmptyAnswerAbortsCleanly() + { + string text = RunWithConsole(new Export(), "\n"); + + StringAssert.Contains(text, "No path provided. Aborting."); + } + + [TestMethod] + public void SearchRunWithoutAQueryAsksForOne() + { + string text = RunWithConsole(new Search(), "dog\n"); + + StringAssert.Contains(text, "Enter the search query"); + StringAssert.Contains(text, "Search results for \"dog\": 1 match(es)"); + } + + [TestMethod] + public void MenuItemsAskAgainOnEveryRun() + { + Action search = Menu.CreateVerbAction(typeof(Search)); + + string text = RunWithConsole(search, "dog\ncat\n"); + + StringAssert.Contains(text, "Search results for \"dog\": 1 match(es)"); + StringAssert.Contains(text, "Search results for \"cat\": 0 match(es)"); + } + + 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)) From 82cd9d9347b5b9bbdecc3733db8fc211796153ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:34:22 +0000 Subject: [PATCH 2/2] Use Assert.Contains in the new menu tests (MSTEST0046) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kj95GdQsZpSCcND7LqacT1 --- ImageDescriber.Test/MenuTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ImageDescriber.Test/MenuTests.cs b/ImageDescriber.Test/MenuTests.cs index 7abfea0..1242090 100644 --- a/ImageDescriber.Test/MenuTests.cs +++ b/ImageDescriber.Test/MenuTests.cs @@ -98,9 +98,9 @@ public void ExportRunWithoutAnOutputPathAsksForOne() { string text = RunWithConsole(new Export(), $"{outputFile}\n"); - StringAssert.Contains(text, "Enter the output file path"); + Assert.Contains("Enter the output file path", text); Assert.IsTrue(File.Exists(outputFile)); - StringAssert.Contains(File.ReadAllText(outputFile), "A dog on a beach."); + Assert.Contains("A dog on a beach.", File.ReadAllText(outputFile)); } finally { @@ -113,7 +113,7 @@ public void ExportRunWithAnEmptyAnswerAbortsCleanly() { string text = RunWithConsole(new Export(), "\n"); - StringAssert.Contains(text, "No path provided. Aborting."); + Assert.Contains("No path provided. Aborting.", text); } [TestMethod] @@ -121,8 +121,8 @@ public void SearchRunWithoutAQueryAsksForOne() { string text = RunWithConsole(new Search(), "dog\n"); - StringAssert.Contains(text, "Enter the search query"); - StringAssert.Contains(text, "Search results for \"dog\": 1 match(es)"); + Assert.Contains("Enter the search query", text); + Assert.Contains("Search results for \"dog\": 1 match(es)", text); } [TestMethod] @@ -132,8 +132,8 @@ public void MenuItemsAskAgainOnEveryRun() string text = RunWithConsole(search, "dog\ncat\n"); - StringAssert.Contains(text, "Search results for \"dog\": 1 match(es)"); - StringAssert.Contains(text, "Search results for \"cat\": 0 match(es)"); + 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);