From 6eba4ef0e7313f5c6c38481fc6ad1766d9880634 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 19 Sep 2026 18:10:43 +1000 Subject: [PATCH] Find split mode snapshots 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. The scan globs `**/*.received.*`, so it matched none of them and the tool reported nothing pending for a project using split mode. There was no way to accept such a snapshot. Verify records the pair for every received file it leaves on disk, split mode included, so the maps already knew about these. They were only ever consulted as a lookup for files the glob had already found, never enumerated. Pairs the glob did not find are now added directly. Only pairs the glob missed are added, so nothing is reported twice, and the inline staging filter is applied to them as well, so a staged inline snapshot the glob deliberately skipped is not reintroduced by its map. 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. The existing code could never surface one, since it only looked maps up by files already found under the root. Pairs outside the root are dropped, so reviewing a directory still cannot reach out of it. Without a map a split mode snapshot stays invisible: the name carries nothing to pair on, and inventing a pairing is worse than reporting nothing. Pinned by a test. Headers name the directory for a split mode snapshot. The file is named after its target, commonly `target.txt`, so the file name alone does not say which test is under review and every split mode snapshot in a run read the same. Flat snapshots are unchanged. --- .../Harness.cs | 20 +++ .../SplitModeTests.cs | 146 ++++++++++++++++++ src/Verify.Terminal/Snapshot.cs | 22 ++- src/Verify.Terminal/SnapshotFinder.cs | 74 +++++++++ 4 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 src/Verify.Terminal.IntegrationTests/SplitModeTests.cs 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 })