diff --git a/src/Verify.Terminal.IntegrationTests/Harness.cs b/src/Verify.Terminal.IntegrationTests/Harness.cs index 2f3b2af..4276613 100644 --- a/src/Verify.Terminal.IntegrationTests/Harness.cs +++ b/src/Verify.Terminal.IntegrationTests/Harness.cs @@ -192,6 +192,26 @@ public IReadOnlyList ReceivedFileNames() => .Select(_ => System.IO.Path.GetFileName(_)) .ToList(); + // Writes a received map by hand, in the same shape Verify writes them, for a scenario that needs + // a map naming something this harness did not produce. + public void WriteMap(string received, string verified) + { + var target = System.IO.Path.Combine(_directory, "obj", "VerifyReceived"); + System.IO.Directory.CreateDirectory(target); + File.WriteAllLines( + System.IO.Path.Combine(target, $"{Guid.NewGuid():N}.txt"), + [received, verified]); + } + + // UseUniqueDirectory in split mode puts the marker in the directory name and names the files + // inside after their targets, so none of them are visible to a scan for `*.received.*` files. + public IReadOnlyList SplitReceivedFiles() => + System.IO.Directory + .GetDirectories(_directory, "*.received") + .SelectMany(_ => System.IO.Directory.GetFiles(_, "*", SearchOption.AllDirectories)) + .Order(StringComparer.Ordinal) + .ToList(); + // Runs the real SnapshotFinder (real globber, real filesystem) over the temp directory. public Snapshot FindSingle() => FindFileSnapshots().Single(); diff --git a/src/Verify.Terminal.IntegrationTests/SplitModeTests.cs b/src/Verify.Terminal.IntegrationTests/SplitModeTests.cs new file mode 100644 index 0000000..bd52bee --- /dev/null +++ b/src/Verify.Terminal.IntegrationTests/SplitModeTests.cs @@ -0,0 +1,146 @@ +namespace Verify.Terminal.IntegrationTests; + +// UseUniqueDirectory in split mode names the directory `{prefix}.received` and the files inside it +// after their targets, so no file name carries a `.received.` marker. A scan for `**/*.received.*` +// finds nothing, and the tool reported no pending snapshots at all for such a project. +// +// Verify records the pair for every received file it leaves on disk, split mode included, so the +// maps are what make these findable. +public class SplitModeTests : IntegrationTestBase +{ + [Fact] + public async Task SingleTarget_IsDetectedAndAccepts() + { + using var harness = new Harness(nameof(SingleTarget_IsDetectedAndAccepts)); + + var settings = SplitSettings(harness, "Single"); + await ProduceReceived(settings); + + // The marker is on the directory, not the file, which is the whole reason this needed fixing. + var received = harness.SplitReceivedFiles().ShouldHaveSingleItem(); + System.IO.Path.GetFileName(received).ShouldNotContain(".received"); + harness.ReceivedFileNames().ShouldBeEmpty(); + + harness.PublishMaps().ShouldBeGreaterThan(0); + + var snapshot = harness.FindSingle(); + snapshot.Received.FullPath.ShouldBe(Normalize(received)); + + // The verified file goes in the sibling `.verified` directory under the same name. + snapshot.Verified.FullPath.ShouldEndWith( + $".verified/{System.IO.Path.GetFileName(received)}"); + + Harness.Accept(snapshot).Succeeded.ShouldBeTrue(); + (await Verifies(settings)).ShouldBeTrue(); + } + + // A test with several targets produces one file per target inside the one directory, so the + // scan has to yield all of them rather than the directory once. + [Fact] + public async Task MultipleTargets_AreAllDetected() + { + using var harness = new Harness(nameof(MultipleTargets_AreAllDetected)); + + var settings = SplitSettings(harness, "Targets"); + var exception = await Record.ExceptionAsync( + async () => await Verifier.Verify( + new List + { + new("txt", "first", "one"), + new("txt", "second", "two") + }, + settings)); + exception.ShouldNotBeNull(); + + harness.SplitReceivedFiles().Count.ShouldBe(2); + harness.PublishMaps().ShouldBeGreaterThan(0); + + harness.FindFileSnapshots().Count.ShouldBe(2); + } + + // Both conventions in one tree. Split mode is additive: the flat received file is still found + // the way it always was. + [Fact] + public async Task AlongsideAFlatSnapshot_BothAreDetected() + { + using var harness = new Harness(nameof(AlongsideAFlatSnapshot_BothAreDetected)); + + await ProduceReceived(SplitSettings(harness, "Split")); + + var flat = harness.CreateSettings(); + flat.UseTypeName("N"); + flat.UseMethodName("Flat"); + await ProduceReceived(flat); + + harness.SplitReceivedFiles().ShouldHaveSingleItem(); + harness.ReceivedFileNames().ShouldHaveSingleItem(); + harness.PublishMaps().ShouldBeGreaterThan(0); + + harness.FindFileSnapshots().Count.ShouldBe(2); + } + + // Split mode names every file after its target, so the file name alone says nothing about which + // test is under review. The directory holding it is what does. + [Fact] + public async Task Header_NamesTheDirectory() + { + using var harness = new Harness(nameof(Header_NamesTheDirectory)); + + await ProduceReceived(SplitSettings(harness, "Header")); + harness.PublishMaps().ShouldBeGreaterThan(0); + + var header = harness.FindSingle().Headers[0]; + + System.IO.Path.GetFileName(header.Path).ShouldBe("target.txt"); + header.Path.ShouldStartWith("N.Header"); + header.Path.ShouldContain(".received/"); + } + + // A map records absolute paths and a test can send its snapshots anywhere, so a map found under + // the scanned root can name a file outside it. Reviewing a directory must not reach out of it. + [Fact] + public async Task MappedOutsideTheRoot_IsNotDetected() + { + using var harness = new Harness(nameof(MappedOutsideTheRoot_IsNotDetected)); + using var outside = new Harness($"{nameof(MappedOutsideTheRoot_IsNotDetected)}-outside"); + + // A real received file, in a directory the scan below never looks at. + await ProduceReceived(SplitSettings(outside, "Outside")); + var stray = outside.SplitReceivedFiles().ShouldHaveSingleItem(); + + // Its map, published under the root that is scanned. + harness.WriteMap(stray, stray.Replace(".received", ".verified", StringComparison.Ordinal)); + + harness.FindFileSnapshots().ShouldBeEmpty(); + } + + // Without a map there is nothing naming the pair, and the file name says nothing either. The + // scan yields nothing rather than guessing, which is the same contract the flat fallback has: + // never invent a pairing it cannot support. + [Fact] + public async Task WithoutMap_IsNotDetected() + { + using var harness = new Harness(nameof(WithoutMap_IsNotDetected)); + + await ProduceReceived(SplitSettings(harness, "NoMap")); + + harness.SplitReceivedFiles().ShouldHaveSingleItem(); + + harness.FindFileSnapshots().ShouldBeEmpty(); + } + + private static VerifySettings SplitSettings(Harness harness, string method) + { + var settings = harness.CreateSettings(); + settings.UseTypeName("N"); + settings.UseMethodName(method); + settings.UseUniqueDirectory(); + settings.UseSplitModeForUniqueDirectory(); + return settings; + } + + // Spectre.IO reports paths with forward slashes, so a path read off the file system has to be + // put in the same form before comparing. + private static string Normalize(string path) => + new FilePath(path).FullPath; +} diff --git a/src/Verify.Terminal/Snapshot.cs b/src/Verify.Terminal/Snapshot.cs index 81a2fa8..0a29874 100644 --- a/src/Verify.Terminal/Snapshot.cs +++ b/src/Verify.Terminal/Snapshot.cs @@ -12,7 +12,7 @@ public IReadOnlyList Headers { get { - var received = new SnapshotHeader(Received.GetFilename().FullPath); + var received = new SnapshotHeader(Describe(Received)); // The verified name is only worth a line of its own when it is not the one the received // name reads as, which is exactly when the snapshot was rerouted. @@ -21,10 +21,28 @@ public IReadOnlyList Headers return [received]; } - return [received, new(Verified.GetFilename().FullPath, "(rerouted)")]; + return [received, new(Describe(Verified), "(rerouted)")]; } } + // A split mode snapshot is named after its target, and the test it belongs to is the directory + // holding it, so the file name alone does not say which snapshot is under review. Every split + // mode snapshot in a run would otherwise read the same, commonly `target.txt`. + private static string Describe(FilePath path) + { + var directory = path.GetDirectory().FullPath; + var separator = directory.LastIndexOf('/'); + var directoryName = separator < 0 ? directory : directory[(separator + 1)..]; + + if (directoryName.EndsWith(".received", StringComparison.Ordinal) || + directoryName.EndsWith(".verified", StringComparison.Ordinal)) + { + return $"{directoryName}/{path.GetFilename().FullPath}"; + } + + return path.GetFilename().FullPath; + } + public Snapshot(FilePath received) { Received = received.NotNull(); diff --git a/src/Verify.Terminal/SnapshotFinder.cs b/src/Verify.Terminal/SnapshotFinder.cs index bfa42a4..54ca00a 100644 --- a/src/Verify.Terminal/SnapshotFinder.cs +++ b/src/Verify.Terminal/SnapshotFinder.cs @@ -10,6 +10,12 @@ public sealed class SnapshotFinder private static readonly StringComparison _pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private static readonly StringComparer _pathComparer = + StringComparer.FromComparison(_pathComparison); + + // The marker Verify puts on a split mode snapshot directory, in place of one on the file name. + private const string _splitMarker = ".received"; + private readonly IGlobber _globber; private readonly IEnvironment _environment; @@ -39,15 +45,83 @@ public ISet Find(DirectoryPath? root = null) .ToDictionary(_ => _.Key, _ => _.ToList(), StringComparer.Ordinal); var result = new HashSet(); + var found = new HashSet(_pathComparer); foreach (var received in Match(root, "**/*.received.*", "received")) { var (verifiedPath, isRerouted) = GetVerified(received, maps, verifiedByDirectory); result.Add(new(received.Path, verifiedPath, isRerouted)); + found.Add(received.Path.FullPath); } + AddMappedOnly(root, maps, found, result); + return result; } + // `UseUniqueDirectory` in split mode names the directory `{prefix}.received` and the files inside + // it after their targets, so nothing in a file name says received and the glob above cannot see + // any of them. Verify records the pair for every received file it leaves on disk, split mode + // included, so the maps are the only thing that can find these, and the only thing that can say + // which verified file each belongs to. + // + // Any received file the glob did find is skipped here, so this only ever adds what a file name + // could not express. Without a map such a snapshot stays invisible: the name carries nothing to + // pair on, and inventing a pairing is worse than reporting nothing. + private static void AddMappedOnly( + DirectoryPath root, + ReceivedMaps maps, + HashSet found, + HashSet result) + { + foreach (var pair in maps.Pairs) + { + var received = new FilePath(pair.Received); + if (!found.Add(received.FullPath)) + { + continue; + } + + // A map records absolute paths, and a test can send its snapshots anywhere, so a map + // found under this root can still name a file outside it. Reviewing a subdirectory must + // not reach out of it. + if (!IsUnder(root, received) || + IsInlineStaging(received)) + { + continue; + } + + var verified = new FilePath(pair.Verified); + result.Add(new(received, verified, IsRerouted(received, verified))); + } + } + + private static bool IsUnder(DirectoryPath root, FilePath path) => + path.FullPath.StartsWith($"{root.FullPath}/", _pathComparison); + + // Display only: what puts "(rerouted)" beside a snapshot whose verified name is not the one its + // received name reads as. A split mode directory carries the received uniqueness that its + // verified twin drops, so the two names differ whenever the test asked for any. + private static bool IsRerouted(FilePath received, FilePath verified) + { + var literal = SplitLiteralVerified(received); + return literal is null || + !literal.Equals(verified.FullPath, _pathComparison); + } + + // The verified path a split mode received path reads as, ie. the same file in the sibling + // `.verified` directory. Null when the path is not in a split mode directory at all. + private static string? SplitLiteralVerified(FilePath received) + { + var directory = received.GetDirectory().FullPath; + if (!directory.EndsWith(_splitMarker, StringComparison.Ordinal)) + { + return null; + } + + var verifiedDirectory = $"{directory[..^_splitMarker.Length]}.verified"; + return $"{verifiedDirectory}/{received.GetFilename().FullPath}"; + } + private IEnumerable Match(DirectoryPath root, string pattern, string marker) => _globber .Match(pattern, new() { Root = root })