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
54 changes: 54 additions & 0 deletions FileDeduplicator.Test/ConsoleCapture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.FileDeduplicator.Test;

using ktsu.FileDeduplicator.Verbs;

/// <summary>
/// Runs a verb with the console redirected, and hands back everything it wrote.
/// </summary>
/// <remarks>
/// The verbs have no return value and no output abstraction -- what they do is what they print --
/// so the console is the only surface a test can assert against. Redirecting it is global state,
/// which is why every class using this is marked <c>[DoNotParallelize]</c>.
/// </remarks>
internal static class ConsoleCapture
{
/// <summary>
/// Runs a verb, feeding it the given answers on stdin.
/// </summary>
/// <param name="verb">The verb to run.</param>
/// <param name="stdin">Lines the verb's prompts will read, or nothing.</param>
/// <returns>Everything the verb wrote to the console.</returns>
internal static string Run(BaseVerb verb, string stdin = "")
{
TextWriter originalOut = Console.Out;
TextReader originalIn = Console.In;

try
{
using StringWriter captured = new();
using StringReader answers = new(stdin);
Console.SetOut(captured);
Console.SetIn(answers);

verb.Run();

return captured.ToString();
}
finally
{
Console.SetOut(originalOut);
Console.SetIn(originalIn);
}
}

/// <summary>
/// Collapses runs of whitespace so assertions describe what a line says rather than the column
/// its markers happen to sit in, and so a CRLF platform reads the same as an LF one.
/// </summary>
/// <param name="output">The captured output.</param>
/// <returns>The output with each line trimmed and its internal whitespace collapsed.</returns>
internal static string Normalize(string output) =>
string.Join('\n', output.Split('\n').Select(line => string.Join(' ', line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))));
}
169 changes: 169 additions & 0 deletions FileDeduplicator.Test/DeduplicateConfirmationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.FileDeduplicator.Test;

using ktsu.FileDeduplicator.Verbs;
using ktsu.Semantics.Paths;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests that the Deduplicate verb shows which copies it is about to delete before it asks for
/// permission to delete them, and that the answer it gets is the one it acts on.
/// </summary>
/// <remarks>
/// The confirmation prompt is the only gate in front of an irreversible deletion, and "keep the
/// shortest filename" is a blunt enough policy to pick the wrong survivor -- r.pdf over
/// report-final-DO-NOT-DELETE.pdf. A prompt that states only a count asks the user to approve an
/// outcome they cannot see, which makes the gate decorative. These tests pin the ordering: the
/// paths come first, the question second.
///
/// They drive the verb through <see cref="BaseVerb.Run()"/> with the console redirected, because
/// the ordering is a property of that method and of nothing else -- a unit test of the listing
/// helper alone would still pass if the call were left out or moved below the prompt.
/// </remarks>
[TestClass]
[DoNotParallelize]
public sealed class DeduplicateConfirmationTests
{
private static string RunDeclining(AbsoluteDirectoryPath root) =>
ConsoleCapture.Normalize(ConsoleCapture.Run(new Deduplicate { PathString = root.WeakString }, "n"));

private static string RunConfirming(AbsoluteDirectoryPath root) =>
ConsoleCapture.Normalize(ConsoleCapture.Run(new Deduplicate { PathString = root.WeakString }, "y"));

private static string BeforeThePrompt(string output)
{
int prompt = output.IndexOf("Proceed with deletion?", StringComparison.Ordinal);
Assert.AreNotEqual(-1, prompt, $"The verb never reached the confirmation prompt. Output was:\n{output}");
return output[..prompt];
}

/// <summary>
/// Every copy that would be deleted has to be named before the prompt, not after the deletion.
/// </summary>
[TestMethod]
public void EveryPathToBeDeletedIsNamedBeforeTheConfirmationPrompt()
{
// Arrange -- the blunt-policy case: the descriptive name loses to the short one
using TempTree tree = new();
AbsoluteFilePath keeper = tree.Write("r.pdf", "the only copy that survives");
AbsoluteFilePath doomed = tree.Write("report-final-DO-NOT-DELETE.pdf", "the only copy that survives");
AbsoluteFilePath alsoDoomed = tree.Write("archive/report-2026-backup.pdf", "the only copy that survives");

// Act
string beforePrompt = BeforeThePrompt(RunDeclining(tree.Root));

// Assert
Assert.Contains($"DELETE: {doomed}", beforePrompt, $"The prompt was shown without naming {doomed}. Output before it was:\n{beforePrompt}");
Assert.Contains($"DELETE: {alsoDoomed}", beforePrompt, $"The prompt was shown without naming {alsoDoomed}. Output before it was:\n{beforePrompt}");
Assert.Contains($"KEEP: {keeper}", beforePrompt, $"The prompt was shown without naming the copy being kept. Output before it was:\n{beforePrompt}");
}

/// <summary>
/// The listing has to cover every group, not just the first one -- a user scrolling past a
/// truncated listing would approve deletions they never saw.
/// </summary>
[TestMethod]
public void EveryDuplicateGroupAppearsInTheListing()
{
// Arrange
using TempTree tree = new();
List<AbsoluteFilePath> doomed =
[
tree.Write("group1/aa.txt", "alpha"),
tree.Write("group2/bb.txt", "beta"),
tree.Write("group3/nested/cc.txt", "gamma"),
];
_ = tree.Write("group1/a.txt", "alpha");
_ = tree.Write("group2/b.txt", "beta");
_ = tree.Write("group3/c.txt", "gamma");

// Act
string beforePrompt = BeforeThePrompt(RunDeclining(tree.Root));

// Assert
foreach (AbsoluteFilePath file in doomed)
{
Assert.Contains($"DELETE: {file}", beforePrompt, $"{file} was not listed before the prompt.");
}
}

/// <summary>
/// The totals printed with the listing describe the same deletions the listing names.
/// </summary>
[TestMethod]
public void TheTotalsMatchTheListingShownAboveThem()
{
// Arrange -- two groups of two 5-byte files, so 2 deletions and 10 bytes
using TempTree tree = new();
_ = tree.Write("a.txt", "alpha");
_ = tree.Write("aa.txt", "alpha");
_ = tree.Write("b.txt", "bravo");
_ = tree.Write("bb.txt", "bravo");

// Act
string beforePrompt = BeforeThePrompt(RunDeclining(tree.Root));

// Assert
Assert.AreEqual(2, beforePrompt.Split("DELETE:").Length - 1, $"Expected two DELETE lines in:\n{beforePrompt}");
Assert.Contains("Files to delete: 2", beforePrompt);
Assert.Contains("Space to reclaim: 10 B", beforePrompt);
}

/// <summary>
/// Showing the listing must not have turned the preview into the deletion: declining still
/// leaves every file on disk.
/// </summary>
[TestMethod]
public void DecliningTheConfirmationLeavesEveryFileOnDisk()
{
// Arrange
using TempTree tree = new();
List<AbsoluteFilePath> all =
[
tree.Write("a.txt", "alpha"),
tree.Write("aa.txt", "alpha"),
tree.Write("b.txt", "beta"),
tree.Write("bb.txt", "beta"),
];

// Act
string output = RunDeclining(tree.Root);

// Assert
Assert.Contains("Aborted.", output);

foreach (AbsoluteFilePath file in all)
{
Assert.IsTrue(TempTree.Exists(file), $"{file} was deleted despite the confirmation being declined.");
}
}

/// <summary>
/// Confirming deletes exactly the copies the listing named, and no others -- the listing is a
/// promise about what the next step does, so it has to be kept.
/// </summary>
[TestMethod]
public void ConfirmingDeletesExactlyTheCopiesTheListingNamed()
{
// Arrange
using TempTree tree = new();
AbsoluteFilePath keeper = tree.Write("a.txt", "alpha");
AbsoluteFilePath doomed = tree.Write("aa.txt", "alpha");
AbsoluteFilePath unique = tree.Write("b.txt", "beta");

// Act
string output = RunConfirming(tree.Root);
string beforePrompt = BeforeThePrompt(output);

// Assert -- what the listing named is gone; what it did not name is not
Assert.Contains($"DELETE: {doomed}", beforePrompt);
Assert.IsFalse(TempTree.Exists(doomed), $"{doomed} was listed for deletion but survived.");
Assert.IsTrue(TempTree.Exists(keeper), $"{keeper} was listed as the kept copy but was deleted.");
Assert.IsTrue(TempTree.Exists(unique), $"{unique} has no duplicate and should never have been touched.");

Assert.Contains("Deleted 1 file(s).", output);
Assert.Contains("Reclaimed 5 B of disk space.", output);
}
}
111 changes: 111 additions & 0 deletions FileDeduplicator.Test/DuplicateReportTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.FileDeduplicator.Test;

using ktsu.Semantics.Paths;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests the listing and totals that Deduplicate and DryRun both print.
/// </summary>
/// <remarks>
/// Both verbs now read their numbers from here, so an error in this one place is an error the
/// preview and the confirmation would agree on -- which is exactly the kind of wrong that nobody
/// catches by reading the output.
/// </remarks>
[TestClass]
public sealed class DuplicateReportTests
{
private static IReadOnlyList<DuplicateGroup> DuplicatesIn(TempTree tree) =>
Deduplicator.FindDuplicates(Deduplicator.GroupByHash(FileHasher.HashFiles(FileScanner.ScanForFiles(tree.Root))));

/// <summary>
/// The plan counts every copy except each group's keeper, and the bytes those copies occupy.
/// </summary>
[TestMethod]
public void PlanCountsEveryCopyExceptEachGroupsKeeper()
{
// Arrange -- 3 copies of a 5-byte content and 2 of a 4-byte one, so 2 + 1 deletions
using TempTree tree = new();
_ = tree.Write("a.txt", "alpha");
_ = tree.Write("aa.txt", "alpha");
_ = tree.Write("aaa.txt", "alpha");
_ = tree.Write("b.txt", "beta");
_ = tree.Write("bb.txt", "beta");
_ = tree.Write("unique.txt", "gamma-and-then-some");

// Act
DeletionPlan plan = DuplicateReport.PlanDeletions(DuplicatesIn(tree));

// Assert
Assert.AreEqual(3, plan.FileCount);
Assert.AreEqual((2 * 5L) + 4L, plan.BytesReclaimable);
}

/// <summary>
/// The listing names one keeper and every other copy in each group, so the two sets together
/// account for every file the group holds.
/// </summary>
[TestMethod]
public void ListingNamesOneKeeperAndEveryOtherCopyPerGroup()
{
// Arrange
using TempTree tree = new();
_ = tree.Write("a.txt", "alpha");
_ = tree.Write("aa.txt", "alpha");
_ = tree.Write("nested/aaa.txt", "alpha");

IReadOnlyList<DuplicateGroup> duplicates = DuplicatesIn(tree);

// Act
DeletionPlan plan = DuplicateReport.PlanDeletions(duplicates);
List<string> keeps = [.. plan.Listing.Where(l => l.Contains("KEEP:", StringComparison.Ordinal))];
List<string> deletes = [.. plan.Listing.Where(l => l.Contains("DELETE:", StringComparison.Ordinal))];

// Assert
Assert.HasCount(1, duplicates);
Assert.HasCount(1, keeps);
Assert.HasCount(2, deletes);

AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(duplicates[0].Files);
Assert.Contains(keeper.WeakString, keeps[0]);

foreach (AbsoluteFilePath file in duplicates[0].Files.Where(f => f != keeper))
{
Assert.IsTrue(
deletes.Exists(l => l.Contains(file.WeakString, StringComparison.Ordinal)),
$"{file} was not listed for deletion.");
}
}

/// <summary>
/// Sizes are rendered in the largest unit that leaves the number above one, so a listing of
/// large files does not ask the reader to count digits.
/// </summary>
/// <param name="bytes">The count to render.</param>
/// <param name="expected">What it should read as.</param>
[TestMethod]
[DataRow(0L, "0 B")]
[DataRow(1023L, "1023 B")]
[DataRow(1024L, "1.0 KB")]
[DataRow(1024L * 1024, "1.0 MB")]
[DataRow((1024L * 1024 * 1024) + (512L * 1024 * 1024), "1.5 GB")]
public void FormatBytesUsesTheLargestUnitThatFits(long bytes, string expected) =>
Assert.AreEqual(expected, DuplicateReport.FormatBytes(bytes));

/// <summary>
/// A run with nothing to delete produces nothing to read.
/// </summary>
[TestMethod]
public void PlanForNoDuplicatesIsEmpty()
{
// Act
DeletionPlan plan = DuplicateReport.PlanDeletions([]);

// Assert
Assert.IsEmpty(plan.Listing);
Assert.AreEqual(0, plan.FileCount);
Assert.AreEqual(0L, plan.BytesReclaimable);
}
}
Loading
Loading