diff --git a/FileDeduplicator.Test/VerbReuseTests.cs b/FileDeduplicator.Test/VerbReuseTests.cs new file mode 100644 index 0000000..8177573 --- /dev/null +++ b/FileDeduplicator.Test/VerbReuseTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FileDeduplicator.Test; + +using ktsu.FileDeduplicator.Verbs; +using ktsu.Semantics.Paths; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that a verb instance asks for a new path each time it runs, however its last run ended. +/// +/// +/// The interactive menu creates one instance of each verb and runs it every time its item is chosen. +/// Each verb reset its path only on the last line of its run, so an early return (nothing found, no +/// duplicates, a declined confirmation) kept the old path. The next run then skipped the prompt, +/// and the path the user typed was read as the answer to the next question instead +/// (ktsu-dev/FileDeduplicator#138). +/// +[TestClass] +[DoNotParallelize] +public sealed class VerbReuseTests +{ + /// + /// Runs a verb against a tree with no duplicates, which returns early, then runs it again. + /// + /// The verb instance, reused as the menu reuses it. + /// The prompt the verb shows when it has no path. + private static void AssertPromptsAgainAfterAnEarlyReturn(BaseVerb verb, string prompt) + { + using TempTree first = new(); + _ = first.Write("a.txt", "alpha"); + _ = first.Write("b.txt", "beta"); + + using TempTree second = new(); + _ = second.Write("c.txt", "gamma"); + + string firstOutput = ConsoleCapture.Run(verb, $"{first.Root.WeakString}\n"); + string secondOutput = ConsoleCapture.Run(verb, $"{second.Root.WeakString}\n"); + + Assert.Contains(prompt, firstOutput); + Assert.Contains(prompt, secondOutput, $"{verb.GetType().Name} reused the previous path without asking."); + Assert.DoesNotContain(first.Root.WeakString, secondOutput, $"{verb.GetType().Name} ran against the previous path."); + } + + [TestMethod] + public void DeduplicatePromptsAgainAfterFindingNoDuplicates() => + AssertPromptsAgainAfterAnEarlyReturn(new Deduplicate(), "Enter the path to deduplicate:"); + + [TestMethod] + public void DryRunPromptsAgainAfterFindingNoDuplicates() => + AssertPromptsAgainAfterAnEarlyReturn(new DryRun(), "Enter the path to scan:"); + + [TestMethod] + public void ScanPromptsAgainAfterFindingNoDuplicates() => + AssertPromptsAgainAfterAnEarlyReturn(new Scan(), "Enter the path to scan:"); + + [TestMethod] + public void StatsPromptsAgainAfterFindingNoFiles() + { + Stats stats = new(); + using TempTree empty = new(); + using TempTree second = new(); + _ = second.Write("c.txt", "gamma"); + + _ = ConsoleCapture.Run(stats, $"{empty.Root.WeakString}\n"); + string secondOutput = ConsoleCapture.Run(stats, $"{second.Root.WeakString}\n"); + + Assert.Contains("Enter the path to analyze:", secondOutput, "Stats reused the previous path without asking."); + } + + /// + /// Declining the deletion is the case the issue describes: the next path typed must not be read + /// as a confirmation against the previous directory. + /// + [TestMethod] + public void DeduplicatePromptsAgainAfterTheUserDeclines() + { + Deduplicate deduplicate = new(); + using TempTree first = new(); + AbsoluteFilePath keeper = first.Write("a.txt", "alpha"); + AbsoluteFilePath copy = first.Write("aa.txt", "alpha"); + + using TempTree second = new(); + _ = second.Write("c.txt", "gamma"); + + _ = ConsoleCapture.Run(deduplicate, $"{first.Root.WeakString}\nn\n"); + string secondOutput = ConsoleCapture.Run(deduplicate, $"{second.Root.WeakString}\n"); + + Assert.Contains("Enter the path to deduplicate:", secondOutput, "Deduplicate reused the previous path without asking."); + Assert.IsTrue(TempTree.Exists(keeper) && TempTree.Exists(copy), "The path typed second was read as a confirmation against the first directory."); + } +} diff --git a/FileDeduplicator/Verbs/BaseVerb.cs b/FileDeduplicator/Verbs/BaseVerb.cs index e59aac8..34c0bcf 100644 --- a/FileDeduplicator/Verbs/BaseVerb.cs +++ b/FileDeduplicator/Verbs/BaseVerb.cs @@ -32,14 +32,24 @@ internal abstract class BaseVerb : BaseVerb where T : BaseVerb public override void Run() { - if (!ValidateArgs()) + // The interactive menu runs the same instance every time its item is chosen, so the path is + // put back on every exit, early returns and exceptions included. Otherwise the next run skips + // the prompt and works on the previous directory. + try { - return; - } + if (!ValidateArgs()) + { + return; + } - isActive = false; - Run((T)this); - isActive = true; + isActive = false; + Run((T)this); + } + finally + { + isActive = true; + PathString = "."; + } } internal abstract void Run(T options); diff --git a/FileDeduplicator/Verbs/Deduplicate.cs b/FileDeduplicator/Verbs/Deduplicate.cs index 6341f03..e53eac2 100644 --- a/FileDeduplicator/Verbs/Deduplicate.cs +++ b/FileDeduplicator/Verbs/Deduplicate.cs @@ -109,7 +109,5 @@ internal override void Run(Deduplicate options) { Console.WriteLine($"Encountered {result.Errors.Count} error(s) during deletion."); } - - PathString = "."; } } diff --git a/FileDeduplicator/Verbs/DryRun.cs b/FileDeduplicator/Verbs/DryRun.cs index f241d47..3b5d70f 100644 --- a/FileDeduplicator/Verbs/DryRun.cs +++ b/FileDeduplicator/Verbs/DryRun.cs @@ -71,7 +71,5 @@ internal override void Run(DryRun options) Console.WriteLine($"Duplicate groups: {duplicates.Count}"); Console.WriteLine($"Files to delete: {plan.FileCount}"); Console.WriteLine($"Space to reclaim: {DuplicateReport.FormatBytes(plan.BytesReclaimable)}"); - - PathString = "."; } } diff --git a/FileDeduplicator/Verbs/Scan.cs b/FileDeduplicator/Verbs/Scan.cs index 62bedc6..952ea1e 100644 --- a/FileDeduplicator/Verbs/Scan.cs +++ b/FileDeduplicator/Verbs/Scan.cs @@ -86,7 +86,5 @@ internal override void Run(Scan options) Console.WriteLine($"Total wasted space: {DuplicateReport.FormatBytes(totalWastedBytes)}"); Console.WriteLine(); Console.WriteLine("Run the 'Deduplicate' command to remove duplicates."); - - PathString = "."; } } diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs index 2ad684f..87446bd 100644 --- a/FileDeduplicator/Verbs/Stats.cs +++ b/FileDeduplicator/Verbs/Stats.cs @@ -104,7 +104,5 @@ internal override void Run(Stats options) Console.WriteLine($" {group.Hash[..12]}... - {group.Files.Count} copies, {DuplicateReport.FormatBytes(group.FileSize)} each, {DuplicateReport.FormatBytes(wasted)} wasted"); } } - - PathString = "."; } }