diff --git a/docs/recording.md b/docs/recording.md index 0a1c738fb..03928388e 100644 --- a/docs/recording.md +++ b/docs/recording.md @@ -25,7 +25,7 @@ public Task Usage() return Verify("TheValue"); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -61,7 +61,7 @@ public Task TryAdd() return Verify("TheValue"); } ``` -snippet source | anchor +snippet source | anchor @@ -85,7 +85,7 @@ public Task RecordingScoped() return Verify(); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -117,7 +117,7 @@ public Task SameKey() return Verify("TheValue"); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -156,7 +156,7 @@ public Task Identifier() return Verify(Recording.Stop("identifier")); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -188,7 +188,7 @@ public Task Case() return Verify("TheValue"); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -223,7 +223,7 @@ public Task Stop() return Verify(appends.Where(_ => _.Name != "name1")); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -255,7 +255,7 @@ public Task StopNotInResult() return Verify("other data"); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -284,7 +284,7 @@ public void IsRecording() Assert.True(Recording.IsRecording()); } ``` -snippet source | anchor +snippet source | anchor This can be helpful if the cost of capturing data, to add to recording, is high. @@ -307,7 +307,7 @@ public Task Clear() return Verify(); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -343,7 +343,7 @@ public Task PauseResume() return Verify(); } ``` -snippet source | anchor +snippet source | anchor Results in: diff --git a/src/Verify.Tests/RecordingTests.cs b/src/Verify.Tests/RecordingTests.cs index 3364ddbf6..0214ad808 100644 --- a/src/Verify.Tests/RecordingTests.cs +++ b/src/Verify.Tests/RecordingTests.cs @@ -21,6 +21,33 @@ public async Task StoppedInChildContextIsNotRecording() Assert.False(Recording.IsRecording()); } + // The engine consumes the recording in a child context, so the null does not flow back + // and the test's own context still holds the stopped state. Starting again after that + // is the supported pattern, not a double start. + [Fact] + public async Task StartAfterConsumedInChildContext() + { + Recording.Start(); + Recording.Add("name", "value1"); + await Task.Run(() => Recording.TryStop(out _)); + + Recording.Start(); + Recording.Add("name", "value2"); + + Assert.True(Recording.TryStop(out var recorded)); + Assert.Equal(["value2"], recorded.Select(_ => _.Data)); + } + + [Fact] + public void StartWhileRecordingStillThrows() + { + using (Recording.Start()) + { + var exception = Assert.Throws(Recording.Start); + Assert.Equal("Recording already started", exception.Message); + } + } + [Fact] public void DisposeAfterStopDoesNotThrow() { diff --git a/src/Verify/Recording/Recording.cs b/src/Verify/Recording/Recording.cs index f24828ca4..2012a6736 100644 --- a/src/Verify/Recording/Recording.cs +++ b/src/Verify/Recording/Recording.cs @@ -79,8 +79,7 @@ public static bool TryStop([NotNullWhen(true)] out IReadOnlyCollection // Snapshot the items, then stop the shared State so the stop is observable // through the caller's reference. recorded = value.Items.ToList(); - value.Clear(); - value.Pause(); + value.Stop(); asyncLocal.Value = null; return true; } @@ -125,7 +124,10 @@ public static IDisposable Start() { var value = asyncLocal.Value; - if (value != null) + // A stopped state is one the caller's context is still holding after the recording + // was consumed, for example by a Verify. That is finished, so starting again is + // valid: only a live recording is a double start. + if (value is {Stopped: false}) { throw new("Recording already started"); } diff --git a/src/Verify/Recording/State.cs b/src/Verify/Recording/State.cs index 73b4371c5..aa0471958 100644 --- a/src/Verify/Recording/State.cs +++ b/src/Verify/Recording/State.cs @@ -23,6 +23,20 @@ public void Add(string name, object item) items.Enqueue(append); } + /// + /// Set once the recording has been consumed. Nulling the AsyncLocal does not flow back + /// to the caller when the engine stops a recording from inside the verification, so the + /// stop has to be observable through the caller's own reference to this state. + /// + public bool Stopped { get; private set; } + + public void Stop() + { + Clear(); + Pause(); + Stopped = true; + } + public void Pause() => Paused = true;