diff --git a/README.md b/README.md index e09e9a7..567cd3d 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,54 @@ await Scientist.ScienceAsync( As always when using async/await, don't forget to call `.ConfigureAwait(false)` where appropriate. +#### Asynchronous cancellation +Experiments can be cancelled part way through running by using any of the three ways below + +1. Via the global `WithCancellationToken` method +```csharp +var cancellationTokenSource = new CancellationTokenSource(); + +await scientist.ExperimentAsync(experimentName, experiment => +{ + experiment.WithCancellationToken(cancellationTokenSource.Token); + experiment.Use(async () => await StartRunningSomething(myData)); + experiment.Try("candidate", async () => await AnAsyncMethod(myData)); +}); +``` + +2. Per method +```csharp +var controlCancellationTokenSource = new CancellationTokenSource(); +var candidateCancellationTokenSource = new CancellationTokenSource(); + +await scientist.ExperimentAsync(experimentName, experiment => +{ + experiment.Use(async () => await StartRunningSomething(myData), controlCancellationTokenSource.Token); + experiment.Try( + "candidate", + async () => await AnAsyncMethod(myData), + candidateCancellationTokenSource.Token + ); +}); +``` + +3. Or overriding the global token on the `Try` and/or `Catch` +```csharp +var cancellationTokenSource = new CancellationTokenSource(); +var candidateCancellationTokenSource = new CancellationTokenSource(); + +await scientist.ExperimentAsync(experimentName, experiment => +{ + experiment.WithCancellationToken(cancellationTokenSource.Token); + experiment.Use(async () => await StartRunningSomething(myData)); + experiment.Try( + "candidate", + async () => await AnAsyncMethod(myData), + candidateCancellationTokenSource.Token + ); +}); +``` + ### Testing When running your test suite, it's helpful to know that the experimental results always match. To help with testing, Scientist has a `ThrowOnMismatches` property that can be set to `true`. Only do this in your test suite! diff --git a/Scientist.net.sln b/Scientist.net.sln index 02c1cb7..26d6be2 100644 --- a/Scientist.net.sln +++ b/Scientist.net.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.16 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11217.181 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Meta", "Meta", "{0FF544BE-E75C-4EF7-AEB3-A534ED5D7BB5}" ProjectSection(SolutionItems) = preProject diff --git a/src/Scientist/Candidate.cs b/src/Scientist/Candidate.cs new file mode 100644 index 0000000..49cb0c9 --- /dev/null +++ b/src/Scientist/Candidate.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GitHub +{ + public class Candidate + { + public Candidate(Func> behavior, CancellationToken cancellationToken = default) + { + Behavior = behavior; + CancellationToken = cancellationToken; + } + public Func> Behavior { get; } + public CancellationToken CancellationToken { get; } + } +} diff --git a/src/Scientist/IExperiment.cs b/src/Scientist/IExperiment.cs index 52924d6..d35eca9 100644 --- a/src/Scientist/IExperiment.cs +++ b/src/Scientist/IExperiment.cs @@ -1,6 +1,7 @@ using Github.Ordering; using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace GitHub.Internals @@ -121,20 +122,20 @@ public interface IExperimentAsync : IExperiment /// Defines the operation to try. /// /// The delegate to execute. - void Try(Func> candidate); + void Try(Func> candidate, CancellationToken cancellationToken = default); /// /// Defines the operation to try. /// /// /// The delegate to execute. - void Try(string name, Func> candidate); + void Try(string name, Func> candidate, CancellationToken cancellationToken = default); /// /// Defines the operation to actually use. /// /// The delegate to execute. - void Use(Func> control); + void Use(Func> control, CancellationToken cancellationToken = default ); /// /// Defines a func used to compare results. @@ -152,6 +153,12 @@ public interface IExperimentAsync : IExperiment /// /// The delgate to execute. void UseCustomOrdering(CustomOrderer customOrdering); + + /// + /// Defines the global cancellation token to use + /// + /// The cancellation token. + void WithCancellationToken(CancellationToken cancellationToken = default); } /// diff --git a/src/Scientist/Internals/Experiment.cs b/src/Scientist/Internals/Experiment.cs index 90bd97c..35f49b0 100644 --- a/src/Scientist/Internals/Experiment.cs +++ b/src/Scientist/Internals/Experiment.cs @@ -1,6 +1,7 @@ using Github.Ordering; using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace GitHub.Internals @@ -16,9 +17,9 @@ private static readonly Action _alwaysThrow private string _name; private int _concurrentTasks; - private Func> _control; + private Candidate _control; - private readonly Dictionary>> _candidates; + private readonly Dictionary> _candidates; private Func _cleaner; private Func _comparison = DefaultComparison; private Func _beforeRun; @@ -31,6 +32,8 @@ private static readonly Action _alwaysThrow private CustomOrderer _customOrderer = behaviors => Task.FromResult(Ordering.Random(behaviors)); + private CancellationToken _cancellationToken = default; + public Experiment(string name, Func> enabled, int concurrentTasks, IResultPublisher resultPublisher) { if (concurrentTasks <= 0) @@ -40,7 +43,7 @@ public Experiment(string name, Func> enabled, int concurrentTasks, IR throw new ArgumentNullException("A result publisher must be specified", nameof(resultPublisher)); _name = name; - _candidates = new Dictionary>>(); + _candidates = new Dictionary>(); _enabled = enabled; _concurrentTasks = concurrentTasks; _resultPublisher = resultPublisher; @@ -60,20 +63,30 @@ public void RunIf(Func block) => public void Thrown(Action block) => _thrown = block; - public void Use(Func> control) => - _control = control; + public void Use(Func> control, CancellationToken cancellationToken = default) + { + var tokenToUse = cancellationToken == default + ? _cancellationToken + : cancellationToken; + _control = new Candidate(control, tokenToUse); + } public void Use(Func control) => - _control = () => Task.FromResult(control()); + _control = new Candidate(() => Task.FromResult(control())); - public void Try(Func> candidate) + public void Try(Func> candidate, CancellationToken cancellationToken = default) { if (_candidates.ContainsKey(CandidateExperimentName)) { throw new InvalidOperationException( "You have already added a default try. Give this candidate a new name with the Try(string, Func>) overload"); } - _candidates.Add(CandidateExperimentName, candidate); + + var tokenToUse = cancellationToken == default + ? _cancellationToken + : cancellationToken; + + _candidates.Add(CandidateExperimentName, new Candidate(candidate, tokenToUse)); } public void Try(Func candidate) @@ -83,17 +96,22 @@ public void Try(Func candidate) throw new InvalidOperationException( "You have already added a default try. Give this candidate a new name with the Try(string, Func>) overload"); } - _candidates.Add(CandidateExperimentName, () => Task.FromResult(candidate())); + _candidates.Add(CandidateExperimentName, new Candidate(() => Task.FromResult(candidate()))); } - public void Try(string name, Func> candidate) + public void Try(string name, Func> candidate, CancellationToken cancellationToken = default) { if (_candidates.ContainsKey(name)) { throw new InvalidOperationException( $"You already have a candidate named {name}. Provide a different name for this test."); } - _candidates.Add(name, candidate); + + var tokenToUse = cancellationToken == default + ? _cancellationToken + : cancellationToken; + + _candidates.Add(name, new Candidate(candidate, tokenToUse)); } public void Try(string name, Func candidate) @@ -103,7 +121,7 @@ public void Try(string name, Func candidate) throw new InvalidOperationException( $"You already have a candidate named {name}. Provide a different name for this test."); } - _candidates.Add(name, () => Task.FromResult(candidate())); + _candidates.Add(name, new Candidate(() => Task.FromResult(candidate()))); } public void Ignore(Func block) => @@ -134,7 +152,8 @@ internal ExperimentInstance Build() => Thrown = _thrown, ThrowOnMismatches = ThrowOnMismatches, ResultPublisher = _resultPublisher, - CustomOrderer = _customOrderer + CustomOrderer = _customOrderer, + CancellationToken = _cancellationToken, }); public void Compare(Func comparison) @@ -161,7 +180,6 @@ public void BeforeRun(Func action) _beforeRun = action; } - public void UseCustomOrdering(Func>, IReadOnlyList>> customOrdering) { _customOrderer = list => Task.FromResult(customOrdering(list)); @@ -171,5 +189,10 @@ public void UseCustomOrdering(CustomOrderer customOrdering) { _customOrderer = customOrdering; } + + public void WithCancellationToken(CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + } } } diff --git a/src/Scientist/Internals/ExperimentInstance.cs b/src/Scientist/Internals/ExperimentInstance.cs index d3d9b4d..13f6a85 100644 --- a/src/Scientist/Internals/ExperimentInstance.cs +++ b/src/Scientist/Internals/ExperimentInstance.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace GitHub.Internals @@ -29,6 +30,7 @@ internal class ExperimentInstance internal readonly bool ThrowOnMismatches; internal readonly IResultPublisher ResultPublisher; internal readonly CustomOrderer CustomOrderer; + internal readonly CancellationToken CancellationToken; public ExperimentInstance(ExperimentSettings settings) { @@ -36,10 +38,10 @@ public ExperimentInstance(ExperimentSettings settings) Behaviors = new List> { - new NamedBehavior(ControlExperimentName, settings.Control), + new NamedBehavior(ControlExperimentName, settings.Control.Behavior, settings.Control.CancellationToken), }; Behaviors.AddRange( - settings.Candidates.Select(c => new NamedBehavior(c.Key, c.Value))); + settings.Candidates.Select(c => new NamedBehavior(c.Key, c.Value.Behavior, c.Value.CancellationToken))); BeforeRun = settings.BeforeRun; Cleaner = settings.Cleaner; @@ -53,10 +55,13 @@ public ExperimentInstance(ExperimentSettings settings) ThrowOnMismatches = settings.ThrowOnMismatches; ResultPublisher = settings.ResultPublisher; CustomOrderer = settings.CustomOrderer; + CancellationToken = settings.CancellationToken; } public async Task Run() { + CancellationToken.ThrowIfCancellationRequested(); + // Determine if experiments should be run. if (!await ShouldExperimentRun().ConfigureAwait(false)) { @@ -64,35 +69,70 @@ public async Task Run() return await Behaviors[0].Behavior().ConfigureAwait(false); } - if (BeforeRun != null) + var observations = new List>(); + var wasCancelled = false; + + try { - await BeforeRun().ConfigureAwait(false); - } + CancellationToken.ThrowIfCancellationRequested(); - var orderedBehaviors = await CustomOrderer(Behaviors).ConfigureAwait(false); + if (BeforeRun != null) + { + await BeforeRun().ConfigureAwait(false); + } - // Break tasks into batches of "ConcurrentTasks" size - var observations = new List>(); - foreach (var behaviors in orderedBehaviors.Chunk(ConcurrentTasks)) + var orderedBehaviors = await CustomOrderer(Behaviors).ConfigureAwait(false); + + // Break tasks into batches of "ConcurrentTasks" size + foreach (var behaviors in orderedBehaviors.Chunk(ConcurrentTasks)) + { + + // Run batch of behaviors simultaneously + var tasks = behaviors.Select(b => + { + + return Observation.New( + b.Name, + b.Behavior, + Comparator, + Thrown, + Cleaner, + b.CancellationToken // ?? CancellationToken // Use global token if override hasnt been given + ); + }); + + + // Collect the observations +#if NET6_0_OR_GREATER + observations.AddRange(await Task.WhenAll(tasks).WaitAsync(CancellationToken).ConfigureAwait(false)); +#else + var allTasks = Task.WhenAll(tasks); + var cancelTask = Task.Delay(Timeout.Infinite, CancellationToken); + + var completed = await Task.WhenAny(allTasks, cancelTask).ConfigureAwait(false); + + if (completed == cancelTask) + { + throw new OperationCanceledException(CancellationToken); + } + + observations.AddRange(await allTasks.ConfigureAwait(false)); +#endif + } + } + catch (Exception ex) { - // Run batch of behaviors simultaneously - var tasks = behaviors.Select(b => + if (ex is OperationCanceledException || ex is TaskCanceledException) { - return Observation.New( - b.Name, - b.Behavior, - Comparator, - Thrown, - Cleaner); - }); - - // Collect the observations - observations.AddRange(await Task.WhenAll(tasks).ConfigureAwait(false)); + wasCancelled = true; + } } var controlObservation = observations.FirstOrDefault(o => o.Name == ControlExperimentName); - var result = new Result(this, observations, controlObservation, Contexts); + wasCancelled = wasCancelled || observations.Exists(o => o.Cancelled); + + var result = new Result(this, observations, controlObservation, Contexts, wasCancelled); try { @@ -103,12 +143,18 @@ public async Task Run() Thrown(Operation.Publish, ex); } - if (ThrowOnMismatches && result.Mismatched) + if (ThrowOnMismatches && result.Mismatched && !wasCancelled) { throw new MismatchException(Name, result); } + if (controlObservation == null || controlObservation.Cancelled) + { + throw new OperationCanceledException("Operation was cancelled during control observation run."); + } + if (controlObservation.Thrown) throw controlObservation.Exception; + return controlObservation.Value; } diff --git a/src/Scientist/Internals/ExperimentSettings.cs b/src/Scientist/Internals/ExperimentSettings.cs index 491cee2..e5a3654 100644 --- a/src/Scientist/Internals/ExperimentSettings.cs +++ b/src/Scientist/Internals/ExperimentSettings.cs @@ -1,6 +1,7 @@ using Github.Ordering; using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace GitHub.Internals @@ -14,11 +15,11 @@ namespace GitHub.Internals internal class ExperimentSettings { public Func BeforeRun { get; set; } - public Dictionary>> Candidates { get; set; } + public Dictionary> Candidates { get; set; } public Func Cleaner { get; set; } public Func Comparator { get; set; } public Dictionary Contexts { get; set; } - public Func> Control { get; set; } + public Candidate Control { get; set; } public Func> Enabled { get; set; } public IEnumerable>> Ignores { get; set; } public string Name { get; set; } @@ -28,5 +29,6 @@ internal class ExperimentSettings public Action Thrown { get; set; } public IResultPublisher ResultPublisher { get; set; } public CustomOrderer CustomOrderer { get; set; } + public CancellationToken CancellationToken { get; set; } } } diff --git a/src/Scientist/NamedBehavior.cs b/src/Scientist/NamedBehavior.cs index aed8834..8ba11f8 100644 --- a/src/Scientist/NamedBehavior.cs +++ b/src/Scientist/NamedBehavior.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; namespace GitHub @@ -7,18 +8,21 @@ public interface INamedBehavior { string Name { get; } Func> Behavior { get; } + CancellationToken CancellationToken { get; } } public class NamedBehavior : INamedBehavior { - public NamedBehavior(string name, Func method) - : this(name, () => Task.FromResult(method())) + public NamedBehavior(string name, Func behavior) { + Name = name; + Behavior = () => Task.FromResult(behavior()); } - public NamedBehavior(string name, Func> method) + public NamedBehavior(string name, Func> behavior, CancellationToken cancellationToken) { - Behavior = method; + Behavior = behavior; Name = name; + CancellationToken = cancellationToken; } /// @@ -30,5 +34,11 @@ public NamedBehavior(string name, Func> method) /// Gets the behavior to execute during an experiment. /// public Func> Behavior { get; } + + /// + /// Gets the cancellation token to use during an experiment. + /// + public CancellationToken CancellationToken { get; } + } } diff --git a/src/Scientist/Observation.cs b/src/Scientist/Observation.cs index 58eb67e..5aa3943 100644 --- a/src/Scientist/Observation.cs +++ b/src/Scientist/Observation.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; namespace GitHub @@ -58,6 +59,15 @@ public T Value private set; } + /// + /// Gets whether the experiment behaviour was cancelled + /// + public bool Cancelled + { + get; + private set; + } + /// /// Determines if another matches this instance. /// @@ -92,7 +102,7 @@ public bool EquivalentTo(Observation other, Func comparat return false; } } - + /// /// Creates a new observation, and runs the experiment. /// @@ -101,11 +111,19 @@ public bool EquivalentTo(Observation other, Func comparat /// The comparison delegate used to determine if an observation is equivalent. /// The delegate used for handling thrown exceptions during equivalency comparisons. /// The observed experiment. - public static async Task> New(string name, Func> block, Func comparison, Action thrown, Func cleaner) + public static async Task> New(string name, Func> block, Func comparison, Action thrown, Func cleaner, CancellationToken cancellationToken) { Observation observation = new Observation(name, thrown, cleaner); - await observation.Run(block).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + await observation.Run(block, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) { + observation.Cancelled = true; + observation.Exception = ex; + } return observation; } @@ -115,17 +133,23 @@ public static async Task> New(string name, Func> /// /// Runs the experiment. /// - internal async Task Run(Func> block) + internal async Task Run(Func> block, CancellationToken cancellationToken) { var start = Stopwatch.GetTimestamp(); try { + cancellationToken.ThrowIfCancellationRequested(); Value = await block().ConfigureAwait(false); } catch (AggregateException ex) { Exception = ex.GetBaseException(); } + catch (OperationCanceledException ex) + { + Cancelled = true; + Exception = ex; + } catch (Exception ex) { Exception = ex; diff --git a/src/Scientist/Result.cs b/src/Scientist/Result.cs index 5a18244..8693a28 100644 --- a/src/Scientist/Result.cs +++ b/src/Scientist/Result.cs @@ -6,7 +6,7 @@ namespace GitHub { public class Result { - internal Result(ExperimentInstance experiment, IEnumerable> observations, Observation control, Dictionary contexts) + internal Result(ExperimentInstance experiment, IEnumerable> observations, Observation control, Dictionary contexts, bool cancelled) { Candidates = observations.Where(o => o != control).ToList(); Control = control; @@ -19,6 +19,8 @@ internal Result(ExperimentInstance experiment, IEnumerable experiment.IgnoreMismatchedObservation(control, m).Result).ToList(); MismatchedObservations = mismatchedObservations.Except(IgnoredObservations).ToList(); + + Cancelled = cancelled; } /// @@ -65,5 +67,10 @@ internal Result(ExperimentInstance experiment, IEnumerable public IReadOnlyDictionary Contexts { get; } + + /// + /// Whether the experiment was cancelled. + /// + public bool Cancelled { get; } } } diff --git a/test/Scientist.Test/ExperimentTests/AsyncCancellationTests.cs b/test/Scientist.Test/ExperimentTests/AsyncCancellationTests.cs new file mode 100644 index 0000000..2de38e4 --- /dev/null +++ b/test/Scientist.Test/ExperimentTests/AsyncCancellationTests.cs @@ -0,0 +1,222 @@ +using FluentAssertions; +using Github.Ordering; +using GitHub; +using GitHub.Internals; +using NSubstitute; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using UnitTests; +using Xunit; + +public class AsyncCancellationTests +{ + public class WithCancellationTokenTests + { + [Fact] + public async Task When_cancelled_during_control_run_should_throw_operation_cancelled() + { + var mock = Substitute.For>(); + mock.Control().Returns(async call => + { + await Task.Delay(5000); + return 42; + }); + mock.Candidate().Returns(Task.FromResult(37)); + + const string experimentName = nameof(When_cancelled_during_control_run_should_throw_operation_cancelled); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var cts = new CancellationTokenSource(); + + cts.Cancel(); + + await Assert.ThrowsAsync(async () => + { + await scientist.ExperimentAsync(experimentName, experiment => + { + experiment.WithCancellationToken(cts.Token); + experiment.UseCustomOrdering(behaviours => Task.FromResult(Ordering.ControlFirst(behaviours))); + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate); + }); + }); + } + + [Fact] + public async Task When_cancelled_after_control_has_ran_should_return_cancelled_in_results() + { + var mock = Substitute.For>(); + mock.Control().Returns(Task.FromResult(42)); + mock.Candidate().Returns(async call => + { + await Task.Delay(2000); + return 37; + }); + + const string experimentName = nameof(When_cancelled_after_control_has_ran_should_return_cancelled_in_results); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var cts = new CancellationTokenSource(); + + cts.CancelAfter(200); + + var result = await scientist.ExperimentAsync(experimentName, 1, experiment => + { + experiment.WithCancellationToken(cts.Token); + experiment.UseCustomOrdering(behaviours => Task.FromResult(Ordering.ControlFirst(behaviours))); + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate); + }); + + result.Should().Be(42); + await mock.Received().Control(); + await mock.Received().Candidate(); + Assert.True(resultPublisher.Results(experimentName).First().Cancelled); + } + } + + public class UseTests + { + [Fact] + public async Task When_cancelled_should_throw_operation_cancelled() + { + var mock = Substitute.For>(); + mock.Control().Returns(async call => + { + await Task.Delay(5000); + return 42; + }); + mock.Candidate().Returns(Task.FromResult(37)); + + const string experimentName = nameof(When_cancelled_should_throw_operation_cancelled); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var cts = new CancellationTokenSource(); + + cts.Cancel(); + + await Assert.ThrowsAsync(async () => + { + var result = await scientist.ExperimentAsync(experimentName, experiment => + { + experiment.UseCustomOrdering(behaviours => Task.FromResult(Ordering.ControlFirst(behaviours))); + experiment.Use(mock.Control, cts.Token); + experiment.Try("candidate", mock.Candidate); + }); + }); + } + } + + public class TryTests + { + [Fact] + public async Task When_cancelled_should_return_cancelled_in_results() + { + var mock = Substitute.For>(); + mock.Control().Returns(Task.FromResult(42)); + mock.Candidate().Returns(async call => + { + await Task.Delay(5000); + return 37; + }); + + const string experimentName = nameof(When_cancelled_should_return_cancelled_in_results); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var cts = new CancellationTokenSource(); + + cts.Cancel(); + + var result = await scientist.ExperimentAsync(experimentName, experiment => + { + experiment.UseCustomOrdering(behaviours => Task.FromResult(Ordering.ControlFirst(behaviours))); + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate, cts.Token); + }); + + result.Should().Be(42); + + await mock.Received().Control(); + await mock.DidNotReceive().Candidate(); + resultPublisher.Results(experimentName).First().Candidates[0].Cancelled.Should().BeTrue(); + } + } + + [Fact] + public async Task Overriden_control_cancellation_token_should_throw_operation_cancelled() + { + var mock = Substitute.For>(); + mock.Control().Returns(async call => + { + await Task.Delay(5000); + return 42; + }); + mock.Candidate().Returns(Task.FromResult(37)); + + const string experimentName = nameof(Overriden_control_cancellation_token_should_throw_operation_cancelled); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var globalCts = new CancellationTokenSource(); + var controlCts = new CancellationTokenSource(); + + controlCts.Cancel(); + + await Assert.ThrowsAsync(async () => + { + await scientist.ExperimentAsync(experimentName, experiment => + { + experiment.WithCancellationToken(globalCts.Token); + experiment.UseCustomOrdering(behaviours => Task.FromResult(Ordering.ControlFirst(behaviours))); + experiment.Use(mock.Control, controlCts.Token); + experiment.Try("candidate", mock.Candidate); + }); + }); + } + + [Fact] + public async Task Overriden_candidate_cancellation_token_should_show_cancelled_in_results() + { + var mock = Substitute.For>(); + mock.Control().Returns(Task.FromResult(42)); + mock.Candidate().Returns(async call => + { + await Task.Delay(2000); + return 37; + }); + + const string experimentName = nameof(Overriden_candidate_cancellation_token_should_show_cancelled_in_results); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var globalCts = new CancellationTokenSource(); + var candidateCts = new CancellationTokenSource(); + + candidateCts.Cancel(); + + var result = await scientist.ExperimentAsync(experimentName, experiment => + { + experiment.WithCancellationToken(globalCts.Token); + experiment.UseCustomOrdering(behaviours => Task.FromResult(Ordering.ControlFirst(behaviours))); + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate, candidateCts.Token); + }); + + result.Should().Be(42); + await mock.Received().Control(); + await mock.DidNotReceive().Candidate(); + Assert.True(resultPublisher.Results(experimentName).First().Cancelled); + } +} \ No newline at end of file diff --git a/test/Scientist.Test/ScientistTests.cs b/test/Scientist.Test/ScientistTests.cs index 0944eb0..8e75f99 100644 --- a/test/Scientist.Test/ScientistTests.cs +++ b/test/Scientist.Test/ScientistTests.cs @@ -142,11 +142,9 @@ public void AllowsReturningNullFromControlOrTest() public void EnsureNullGuardIsWorking() { #if !DEBUG - var ex = Assert.Throws(() => + Assert.Throws(() => Scientist.Science(null, _ => { }) - ); - - Assert.IsType(ex.InnerException); + ); #endif }