Skip to content

Commit 273facf

Browse files
authored
Inline append already applied (#857)
* An append onto the same content is AlreadyApplied A multi-targeted project transitioning to inline snapshots fails the same call site under every framework, and each one queues an append. Accepting the first writes the literal the rest are still carrying, so the second accept found a Snapshot call in the way and refused - reporting a failure over a source file that was already right, and sending the reader off to re-run a test with nothing left to say. TryAppend now reads the chained call's argument before refusing. Same content is AlreadyApplied; different content is still NotFound, since that one genuinely cannot say what it wants until it has been re-run against the literal now in the source. Compared by value rather than by text, so a literal written in another shape still counts and F# answers as C# does despite the layout of its triple quoted literal. WalkChain reports the position of the call it found rather than the fact of it, because a caller deciding what to do about one has to read its argument. * Move the AlreadyApplied paragraph into inline.source.md docs/inline.md is generated, so the paragraph was written into the output and the next build stripped it back out. It belongs in the mdsource the generator reads; the generated file is unchanged either way. * Update Directory.Build.props
1 parent 5bae1e0 commit 273facf

7 files changed

Lines changed: 113 additions & 13 deletions

File tree

docs/inline.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ For the staging fallback, where no viewer could be resolved and the patch is a f
211211

212212
`Apply` returns `Applied`, `AlreadyApplied` (the literal already matches), `NotFound` (the source changed since the test run — tell the user to re-run rather than retrying), or a failure with a message (locked file, unreadable source), which is retryable.
213213

214+
`AlreadyApplied` covers an `Append` onto a call that already has a `Snapshot` call holding this same content, which is what a multi-targeted project transitioning to inline meets: every framework fails the call site and queues an append, and whichever is accepted first writes the literal the rest are carrying. Only a chained call holding *different* content is `NotFound` — that one genuinely cannot say what it wants until it has been re-run against the literal now in the source. Accepting one framework's append before the others have run does mean the queue never sees them together, so a real disagreement between frameworks is reported as that `NotFound` rather than as a conflict to pick from.
215+
214216
`Remove` mode patches are configuration changes with nothing to review: apply them directly; `AddInlineAsync` refuses them.
215217

216218

docs/mdsource/inline.source.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ For the staging fallback, where no viewer could be resolved and the patch is a f
204204

205205
`Apply` returns `Applied`, `AlreadyApplied` (the literal already matches), `NotFound` (the source changed since the test run — tell the user to re-run rather than retrying), or a failure with a message (locked file, unreadable source), which is retryable.
206206

207+
`AlreadyApplied` covers an `Append` onto a call that already has a `Snapshot` call holding this same content, which is what a multi-targeted project transitioning to inline meets: every framework fails the call site and queues an append, and whichever is accepted first writes the literal the rest are carrying. Only a chained call holding *different* content is `NotFound` — that one genuinely cannot say what it wants until it has been re-run against the literal now in the source. Accepting one framework's append before the others have run does mean the queue never sees them together, so a real disagreement between frameworks is reported as that `NotFound` rather than as a conflict to pick from.
208+
207209
`Remove` mode patches are configuration changes with nothing to review: apply them directly; `AddInlineAsync` refuses them.
208210

209211

src/DiffEngine.Tests/InlinePatcherFsTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,30 @@ public async Task AppendIsRefusedWhenOneIsAlreadyChained()
480480
await Assert.That(reason).Contains("already has a Snapshot call");
481481
}
482482

483+
// The second framework of a multi-targeted transition, appending onto the literal the first
484+
// one's accept just wrote. Read by value, so F# gets the same answer C# does even though the
485+
// layout of its triple quoted literal is nothing like the content it stands for
486+
[Test]
487+
public async Task AppendOntoTheSameContentIsAlreadyApplied()
488+
{
489+
var source = Test(" Verifier.Verify(15).Snapshot(\"same\").ToTask()");
490+
491+
var status = TryApply(source, 5, InlinePatchMode.Append, null, "same", out _, out _);
492+
493+
await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied);
494+
}
495+
496+
[Test]
497+
public async Task AppendingTheSameContentTwiceIsAlreadyApplied()
498+
{
499+
var source = Test(" Verifier.Verify(15).ToTask()");
500+
501+
TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var applied, out _);
502+
var status = TryApply(applied, 5, InlinePatchMode.Append, null, "a\nb", out _, out _);
503+
504+
await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied);
505+
}
506+
483507
[Test]
484508
public async Task AppendSkipsAVerifyOnAnotherReceiver()
485509
{

src/DiffEngine.Tests/InlinePatcherTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,36 @@ public async Task AppendIsRefusedWhenOneIsAlreadyChained()
821821
await Assert.That(reason).Contains("already has a Snapshot call");
822822
}
823823

824+
/// <summary>
825+
/// The multi-targeted transition, which is where an append lands on a chained call that is
826+
/// not in its way at all. Two frameworks fail the same snapshot, each queues an append, and
827+
/// accepting the first writes the literal the second is still carrying. Refused, that reads
828+
/// as a failure over a source file that is already right, and sends the reader off to re-run
829+
/// a test with nothing left to say.
830+
/// </summary>
831+
[Test]
832+
public async Task AppendOntoTheSameContentIsAlreadyApplied()
833+
{
834+
var source = Method(" await Verify(value)\n .Snapshot(\"new\");");
835+
836+
var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out _);
837+
838+
await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied);
839+
}
840+
841+
// The two halves of that have to agree, or the second framework's accept is refused over a
842+
// literal the first one wrote from the very content being compared against it
843+
[Test]
844+
public async Task AppendingTheSameContentTwiceIsAlreadyApplied()
845+
{
846+
var source = Method(" await Verify(value);");
847+
848+
TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var applied, out _);
849+
var status = TryApply(applied, 5, InlinePatchMode.Append, null, "a\nb", out _, out _);
850+
851+
await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied);
852+
}
853+
824854
[Test]
825855
public async Task AppendWithNoVerifyCall()
826856
{

src/DiffEngine/Inline/InlineApplier.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ public static InlineApplyResult CanApply(InlinePatch patch) =>
4040
/// over. Applied for yes, NotFound for no, and Failed where the source could not be read.
4141
/// <para>
4242
/// Apart from CanApply in one way, and only for Append: a call that already has a Snapshot call
43-
/// chained onto it answers yes here and is refused there. Both are right. An accept has nowhere
44-
/// to put the literal it is carrying and says to re-run; a producer asking whether this call
45-
/// site can host an inline snapshot has its answer, and taking the verification off inline
46-
/// because another process got there first would be the wrong lesson to draw.
43+
/// chained onto it holding other content answers yes here and is refused there. Both are right.
44+
/// An accept has nowhere to put the literal it is carrying and says to re-run; a producer
45+
/// asking whether this call site can host an inline snapshot has its answer, and taking the
46+
/// verification off inline because another process got there first would be the wrong lesson
47+
/// to draw. Where the chained call holds this same content there is nothing to tell apart and
48+
/// both say yes, CanApply as AlreadyApplied.
4749
/// </para>
4850
/// </summary>
4951
public static InlineApplyResult CanAnchor(InlinePatch patch) =>

src/DiffEngine/Inline/InlinePatcher.cs

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -433,10 +433,22 @@ static PatchStatus TryAppend(
433433
return PatchStatus.Applied;
434434
}
435435

436-
var insertAt = WalkChain(source, scan, closeParen + 1, methodName, out var alreadyChained);
437-
// Another process may have appended one between the run and the accept
438-
if (alreadyChained)
436+
var insertAt = WalkChain(source, scan, closeParen + 1, methodName, out var chained);
437+
// Another process may have appended one between the run and the accept, and two
438+
// frameworks failing the same call site is the ordinary way that happens: each queues an
439+
// append, and accepting the first leaves the second with nowhere to put a literal that is
440+
// already there. Only the content tells the two apart. The same snapshot is done, and
441+
// saying so matters - a refusal reads as a failure, and the reader who sent two identical
442+
// snapshots and got one applied and one rejected has no way to see that their source is
443+
// already right. A different one is a call site that cannot say what it wants until it has
444+
// been re-run against the literal it now has.
445+
if (chained >= 0)
439446
{
447+
if (HoldsContent(source, scan, chained, newContent))
448+
{
449+
return PatchStatus.AlreadyApplied;
450+
}
451+
440452
failReason = $"The call near line {lineHint} already has a {methodName} call. Re-run the test.";
441453
return PatchStatus.NotFound;
442454
}
@@ -454,6 +466,31 @@ static PatchStatus TryAppend(
454466
return PatchStatus.Applied;
455467
}
456468

469+
/// <summary>
470+
/// Whether the call at <paramref name="openParen"/> already carries
471+
/// <paramref name="content"/> as its expected argument.
472+
/// <para>
473+
/// What the argument means rather than what it says, so a literal the append would have
474+
/// written in another shape - a different delimiter, a different indent - still counts as the
475+
/// same snapshot. Anything that is not a literal at all, or is hidden behind another named
476+
/// argument, is not this content: no answer can be read out of it, and the caller's other
477+
/// branch says to re-run, which is where a call site nobody can make sense of belongs.
478+
/// </para>
479+
/// </summary>
480+
static bool HoldsContent(string source, SourceScan scan, int openParen, string content)
481+
{
482+
if (!TryReadArguments(source, scan, openParen, out var expected) ||
483+
expected.IsAbsent ||
484+
expected.BlockedByName)
485+
{
486+
return false;
487+
}
488+
489+
var argument = source.Substring(expected.Start, expected.End - expected.Start);
490+
return scan.Language.TryParse(argument, out var value) &&
491+
value == content;
492+
}
493+
457494
/// <summary>
458495
/// Removes the Snapshot call, along with the whitespace and line break that preceded it so no
459496
/// blank line is left behind.
@@ -533,11 +570,13 @@ static PatchStatus TryRemove(
533570
/// Walks the calls chained onto an invocation and returns where a call should be appended:
534571
/// the end of the chain, or the point in front of the language's
535572
/// <see cref="SourceLanguage.ChainTerminator"/> when the chain ends in one.
536-
/// <paramref name="found"/> is set when one of them is a call to <paramref name="name"/>.
573+
/// <paramref name="found"/> is the open paren of the first call to <paramref name="name"/>
574+
/// among them, or -1 where there is none. The position rather than the fact of it, because a
575+
/// caller deciding what to do about one has to read its argument.
537576
/// </summary>
538-
static int WalkChain(string source, SourceScan scan, int index, string name, out bool found)
577+
static int WalkChain(string source, SourceScan scan, int index, string name, out int found)
539578
{
540-
found = false;
579+
found = -1;
541580
var terminator = scan.Language.ChainTerminator;
542581
// Where the chain was before the terminating call, which is where an appended one goes:
543582
// in front of the terminator, and behind the whitespace and line break that introduced it
@@ -569,9 +608,10 @@ static int WalkChain(string source, SourceScan scan, int index, string name, out
569608
break;
570609
}
571610

572-
if (IsCall(source, nameStart, cursor, name))
611+
if (found < 0 &&
612+
IsCall(source, nameStart, cursor, name))
573613
{
574-
found = true;
614+
found = paren;
575615
}
576616

577617
if (terminator != null &&

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<Project>
33
<PropertyGroup>
4-
<Version>20.1.1</Version>
4+
<Version>20.1.2</Version>
55
<AssemblyVersion>1.0.0</AssemblyVersion>
66
<PackageTags>Testing, Snapshot, Diff, Compare</PackageTags>
77
<Description>Launches diff tools based on file extensions. Designed to be consumed by snapshot testing libraries.</Description>

0 commit comments

Comments
 (0)