diff --git a/README.md b/README.md index 8a8aa43..e09e9a7 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ public bool CanAccess(IUser user) Wrap a `Use` block around the code's original behavior, and wrap `Try` around the new behavior. Invoking `Scientist.Science` will always return whatever the `Use` block returns, but it does a bunch of stuff behind the scenes: * It decides whether or not to run the `Try` block, -* Randomizes the order in which `Use` and `Try` blocks are run, +* By default randomises the order in which `Use` and `Try` blocks are run, * Measures the durations of all behaviors, * Compares the result of `Try` to the result of `Use`, * Swallows (but records) any exceptions raised in the `Try` block, and @@ -341,6 +341,41 @@ public bool CanAccess(IUser user) } ``` +### Changing the order + +By default the library randomises the order in which behaviors (control & candidates) are ran however, under certain cirumstances you might want to run your own ordering so we provide a handy method `UseCustomOrdering`. +```csharp +scientist.Experiment(experimentName, experiment => +{ + experiment.UseCustomOrdering(Ordering.ControlFirst); + // ... +}); +``` +There are some pre-written alogrithms +```csharp +Ordering.Random +Ordering.ControlFirst +Ordering.ControlLast +``` +But if you need to rock your own then you can write something like below +```csharp + private static int _seed = 123; + + public static IReadOnlyList> SeededExperimentOrderer(IReadOnlyList> behaviors) + { + var random = new Random(_seed); + return behaviors.OrderBy(_ => random.Next()).ToList(); + } + +// ... + +scientist.Experiment(experimentName, experiment => +{ + experiment.UseCustomOrdering(SeededExperimentOrderer); + // ... +}); +``` + ## Alternatives Here are other implementations of Scientist available in different languages. diff --git a/src/Scientist/IExperiment.cs b/src/Scientist/IExperiment.cs index d4d037e..52924d6 100644 --- a/src/Scientist/IExperiment.cs +++ b/src/Scientist/IExperiment.cs @@ -1,4 +1,6 @@ -using System; +using Github.Ordering; +using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace GitHub.Internals @@ -78,8 +80,14 @@ public interface IExperiment : IExperiment /// /// Defines the check to run to determine if mismatches should be ignored. /// - /// The delegate to execute + /// The delegate to execute. void Ignore(Func block); + + /// + /// Defines the custom ordering to run on the behaviors + /// + /// The delgate to execute. + void UseCustomOrdering(Func>, IReadOnlyList>> ordering); } /// @@ -136,8 +144,14 @@ public interface IExperimentAsync : IExperiment /// /// Defines the check to run to determine if mismatches should be ignored. /// - /// The delegate to execute + /// The delegate to execute. void Ignore(Func> block); + + /// + /// Defines the custom ordering to run on the behaviors + /// + /// The delgate to execute. + void UseCustomOrdering(CustomOrderer customOrdering); } /// diff --git a/src/Scientist/Internals/Experiment.cs b/src/Scientist/Internals/Experiment.cs index bb7bd8d..90bd97c 100644 --- a/src/Scientist/Internals/Experiment.cs +++ b/src/Scientist/Internals/Experiment.cs @@ -1,4 +1,5 @@ -using System; +using Github.Ordering; +using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -28,6 +29,8 @@ private static readonly Action _alwaysThrow private readonly Dictionary _contexts = new Dictionary(); private readonly IResultPublisher _resultPublisher; + private CustomOrderer _customOrderer = behaviors => Task.FromResult(Ordering.Random(behaviors)); + public Experiment(string name, Func> enabled, int concurrentTasks, IResultPublisher resultPublisher) { if (concurrentTasks <= 0) @@ -130,7 +133,8 @@ internal ExperimentInstance Build() => RunIf = _runIf, Thrown = _thrown, ThrowOnMismatches = ThrowOnMismatches, - ResultPublisher = _resultPublisher + ResultPublisher = _resultPublisher, + CustomOrderer = _customOrderer }); public void Compare(Func comparison) @@ -156,5 +160,16 @@ public void BeforeRun(Func action) { _beforeRun = action; } + + + public void UseCustomOrdering(Func>, IReadOnlyList>> customOrdering) + { + _customOrderer = list => Task.FromResult(customOrdering(list)); + } + + public void UseCustomOrdering(CustomOrderer customOrdering) + { + _customOrderer = customOrdering; + } } } diff --git a/src/Scientist/Internals/ExperimentInstance.cs b/src/Scientist/Internals/ExperimentInstance.cs index 6e418bb..d3d9b4d 100644 --- a/src/Scientist/Internals/ExperimentInstance.cs +++ b/src/Scientist/Internals/ExperimentInstance.cs @@ -1,4 +1,5 @@ -using System; +using Github.Ordering; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -16,7 +17,7 @@ internal class ExperimentInstance internal readonly string Name; internal readonly int ConcurrentTasks; - internal readonly List Behaviors; + internal readonly List> Behaviors; internal readonly Func Cleaner; internal readonly Func Comparator; internal readonly Func BeforeRun; @@ -27,19 +28,18 @@ internal class ExperimentInstance internal readonly Action Thrown; internal readonly bool ThrowOnMismatches; internal readonly IResultPublisher ResultPublisher; - - static Random _random = new Random(DateTimeOffset.UtcNow.Millisecond); - + internal readonly CustomOrderer CustomOrderer; + public ExperimentInstance(ExperimentSettings settings) { Name = settings.Name; - Behaviors = new List + Behaviors = new List> { - new NamedBehavior(ControlExperimentName, settings.Control), + new NamedBehavior(ControlExperimentName, settings.Control), }; Behaviors.AddRange( - settings.Candidates.Select(c => new NamedBehavior(c.Key, c.Value))); + settings.Candidates.Select(c => new NamedBehavior(c.Key, c.Value))); BeforeRun = settings.BeforeRun; Cleaner = settings.Cleaner; @@ -52,6 +52,7 @@ public ExperimentInstance(ExperimentSettings settings) Thrown = settings.Thrown; ThrowOnMismatches = settings.ThrowOnMismatches; ResultPublisher = settings.ResultPublisher; + CustomOrderer = settings.CustomOrderer; } public async Task Run() @@ -68,12 +69,7 @@ public async Task Run() await BeforeRun().ConfigureAwait(false); } - // Randomize ordering... - NamedBehavior[] orderedBehaviors; - lock (_random) - { - orderedBehaviors = Behaviors.OrderBy(b => _random.Next()).ToArray(); - } + var orderedBehaviors = await CustomOrderer(Behaviors).ConfigureAwait(false); // Break tasks into batches of "ConcurrentTasks" size var observations = new List>(); @@ -95,7 +91,7 @@ public async Task Run() } var controlObservation = observations.FirstOrDefault(o => o.Name == ControlExperimentName); - + var result = new Result(this, observations, controlObservation, Contexts); try @@ -115,7 +111,7 @@ public async Task Run() if (controlObservation.Thrown) throw controlObservation.Exception; return controlObservation.Value; } - + /// /// Does allow the experiment to run? /// @@ -149,7 +145,7 @@ public async Task IgnoreMismatchedObservation(Observation contr return false; } } - + /// /// Determine whether or not the experiment should run. /// @@ -167,29 +163,5 @@ async Task ShouldExperimentRun() return false; } } - - internal class NamedBehavior - { - public NamedBehavior(string name, Func behavior) - : this(name, () => Task.FromResult(behavior())) - { - } - - public NamedBehavior(string name, Func> behavior) - { - Behavior = behavior; - Name = name; - } - - /// - /// Gets the behavior to execute during an experiment. - /// - public Func> Behavior { get; } - - /// - /// Gets the name of the behavior. - /// - public string Name { get; } - } } } \ No newline at end of file diff --git a/src/Scientist/Internals/ExperimentSettings.cs b/src/Scientist/Internals/ExperimentSettings.cs index a824fa8..491cee2 100644 --- a/src/Scientist/Internals/ExperimentSettings.cs +++ b/src/Scientist/Internals/ExperimentSettings.cs @@ -1,4 +1,5 @@ -using System; +using Github.Ordering; +using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -26,5 +27,6 @@ internal class ExperimentSettings public bool ThrowOnMismatches { get; set; } public Action Thrown { get; set; } public IResultPublisher ResultPublisher { get; set; } + public CustomOrderer CustomOrderer { get; set; } } } diff --git a/src/Scientist/NamedBehavior.cs b/src/Scientist/NamedBehavior.cs new file mode 100644 index 0000000..aed8834 --- /dev/null +++ b/src/Scientist/NamedBehavior.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; + +namespace GitHub +{ + public interface INamedBehavior + { + string Name { get; } + Func> Behavior { get; } + } + public class NamedBehavior : INamedBehavior + { + public NamedBehavior(string name, Func method) + : this(name, () => Task.FromResult(method())) + { + } + + public NamedBehavior(string name, Func> method) + { + Behavior = method; + Name = name; + } + + /// + /// Gets the name of the behavior. + /// + public string Name { get; } + + /// + /// Gets the behavior to execute during an experiment. + /// + public Func> Behavior { get; } + } +} diff --git a/src/Scientist/Ordering/Ordering.cs b/src/Scientist/Ordering/Ordering.cs new file mode 100644 index 0000000..067a17c --- /dev/null +++ b/src/Scientist/Ordering/Ordering.cs @@ -0,0 +1,34 @@ +using GitHub; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Github.Ordering +{ + public delegate Task>> CustomOrderer(IReadOnlyList> namedBehaviors); + + public class Ordering + { + public static IReadOnlyList> ControlFirst(IReadOnlyList> namedBehaviors) + { + return namedBehaviors.OrderByDescending(namedBehavior => namedBehavior.Name == "control").ToList(); + } + + public static IReadOnlyList> ControlLast(IReadOnlyList> namedBehaviors) + { + return ControlFirst(namedBehaviors).Reverse().ToList(); + } + + static Random _random = new Random(DateTimeOffset.UtcNow.Millisecond); + + public static IReadOnlyList> Random(IReadOnlyList> namedBehaviors) + { + lock (_random) + { + return namedBehaviors.OrderBy(b => _random.Next()).ToArray(); + } + } + } + +} diff --git a/src/Scientist/Scientist.csproj b/src/Scientist/Scientist.csproj index b12c6d5..812bad6 100644 --- a/src/Scientist/Scientist.csproj +++ b/src/Scientist/Scientist.csproj @@ -31,7 +31,7 @@ - + diff --git a/src/Scientist/packages.lock.json b/src/Scientist/packages.lock.json index d794fe5..c1dec41 100644 --- a/src/Scientist/packages.lock.json +++ b/src/Scientist/packages.lock.json @@ -2,15 +2,6 @@ "version": 1, "dependencies": { ".NETFramework,Version=v4.5.1": { - "Microsoft.NETFramework.ReferenceAssemblies": { - "type": "Direct", - "requested": "[1.0.3, )", - "resolved": "1.0.3", - "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", - "dependencies": { - "Microsoft.NETFramework.ReferenceAssemblies.net451": "1.0.3" - } - }, "Microsoft.SourceLink.GitHub": { "type": "Direct", "requested": "[8.0.0, )", @@ -26,11 +17,6 @@ "resolved": "8.0.0", "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, - "Microsoft.NETFramework.ReferenceAssemblies.net451": { - "type": "Transitive", - "resolved": "1.0.3", - "contentHash": "vVPinxdLrwoX81ApbNIHDBI6qymQEy8eSOxDNBgKJtc2+cifnF0oT1U2d3EFx+V5O68yaqna2myZJNsgKCpVkA==" - }, "Microsoft.SourceLink.Common": { "type": "Transitive", "resolved": "8.0.0", diff --git a/test/Scientist.Test/ExperimentTests/Ordering/OrderingAsyncTests.cs b/test/Scientist.Test/ExperimentTests/Ordering/OrderingAsyncTests.cs new file mode 100644 index 0000000..0785ac5 --- /dev/null +++ b/test/Scientist.Test/ExperimentTests/Ordering/OrderingAsyncTests.cs @@ -0,0 +1,153 @@ +using FluentAssertions; +using Github.Ordering; +using GitHub; +using GitHub.Internals; +using NSubstitute; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using UnitTests; +using Xunit; + +public class OrderingAsyncTests +{ + [Fact] + public async Task No_custom_ordering_specified_should_run_using_default_random() + { + const string experimentName = nameof(No_custom_ordering_specified_should_run_using_default_random); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var timesToRun = 10; + + var controlRanFirst = false; + var candidateRanFirst = false; + + for (int i = 0; i < timesToRun; i++) + { + var mock = Substitute.For>(); + mock.Control().Returns(42); + mock.Candidate().Returns(42); + + var result = await scientist.ExperimentAsync(experimentName, experiment => + { + experiment.ThrowOnMismatches = true; + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate); + }); + + result.Should().Be(42); + resultPublisher.Results(experimentName).First().Matched.Should().BeTrue(); + + var firstCall = mock.ReceivedCalls().First(); + + if (firstCall.GetMethodInfo().Name == "Control") + { + controlRanFirst = true; + } + + if (firstCall.GetMethodInfo().Name == "Candidate") + { + candidateRanFirst = true; + } + } + + controlRanFirst.Should().BeTrue($"Out of {timesToRun} runs, control should be seen to be ran first at least once"); + candidateRanFirst.Should().BeTrue($"Out of {timesToRun} runs, candidate should be seen to be ran first at least once"); + } + + [Fact] + public async Task ControlFirst_specified_should_run_first() + { + var mock = Substitute.For>(); + mock.Control().Returns(42); + mock.Candidate().Returns(42); + const string experimentName = nameof(ControlFirst_specified_should_run_first); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var result = await scientist.ExperimentAsync(experimentName, experiment => + { + experiment.ThrowOnMismatches = true; + experiment.UseCustomOrdering(behaviors => Task.FromResult(Ordering.ControlFirst(behaviors))); + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate); + }); + + result.Should().Be(42); + resultPublisher.Results(experimentName).First().Matched.Should().BeTrue(); + Received.InOrder(() => + { + mock.Received().Control(); + mock.Received().Candidate(); + }); + } + + [Fact] + public async Task ControlLast_specified_should_run_last() + { + var mock = Substitute.For>(); + mock.Control().Returns(42); + mock.Candidate().Returns(42); + const string experimentName = nameof(ControlLast_specified_should_run_last); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var result = await scientist.ExperimentAsync(experimentName, experiment => + { + experiment.ThrowOnMismatches = true; + experiment.UseCustomOrdering(behaviors => Task.FromResult(Ordering.ControlLast(behaviors))); + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate); + }); + + result.Should().Be(42); + resultPublisher.Results(experimentName).First().Matched.Should().BeTrue(); + Received.InOrder(() => + { + mock.Received().Candidate(); + mock.Received().Control(); + }); + } + + [Fact] + public async Task Passed_custom_ordering_method_runs() + { + var mock = Substitute.For>(); + mock.Control().Returns(42); + mock.Candidate().Returns(42); + const string experimentName = nameof(ControlLast_specified_should_run_last); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var result = await scientist.ExperimentAsync(experimentName, experiment => + { + experiment.ThrowOnMismatches = true; + experiment.UseCustomOrdering(SeededExperimentOrderer); + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate); + }); + + result.Should().Be(42); + resultPublisher.Results(experimentName).First().Matched.Should().BeTrue(); + Received.InOrder(() => + { + mock.Received().Candidate(); + mock.Received().Control(); + }); + } + + private static int _seed = 123; + + public static async Task>> SeededExperimentOrderer(IReadOnlyList> behaviors) + { + var random = new Random(_seed); + return behaviors.OrderBy(_ => random.Next()).ToList(); + } +} + diff --git a/test/Scientist.Test/ExperimentTests/Ordering/OrderingTests.cs b/test/Scientist.Test/ExperimentTests/Ordering/OrderingTests.cs new file mode 100644 index 0000000..aa43594 --- /dev/null +++ b/test/Scientist.Test/ExperimentTests/Ordering/OrderingTests.cs @@ -0,0 +1,152 @@ +using FluentAssertions; +using Github.Ordering; +using GitHub; +using GitHub.Internals; +using NSubstitute; +using System; +using System.Collections.Generic; +using System.Linq; +using UnitTests; +using Xunit; + +public class OrderingTests +{ + [Fact] + public void No_custom_ordering_specified_should_run_using_default_random() + { + const string experimentName = nameof(No_custom_ordering_specified_should_run_using_default_random); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var timesToRun = 10; + + var controlRanFirst = false; + var candidateRanFirst = false; + + for (int i = 0; i < timesToRun; i++) + { + var mock = Substitute.For>(); + mock.Control().Returns(42); + mock.Candidate().Returns(42); + + var result = scientist.Experiment(experimentName, experiment => + { + experiment.ThrowOnMismatches = true; + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate); + }); + + result.Should().Be(42); + resultPublisher.Results(experimentName).First().Matched.Should().BeTrue(); + + var firstCall = mock.ReceivedCalls().First(); + + if (firstCall.GetMethodInfo().Name == "Control") + { + controlRanFirst = true; + } + + if (firstCall.GetMethodInfo().Name == "Candidate") + { + candidateRanFirst = true; + } + } + + controlRanFirst.Should().BeTrue($"Out of {timesToRun} runs, control should be seen to be ran first at least once"); + candidateRanFirst.Should().BeTrue($"Out of {timesToRun} runs, candidate should be seen to be ran first at least once"); + } + + [Fact] + public void ControlFirst_specified_should_run_first() + { + var mock = Substitute.For>(); + mock.Control().Returns(42); + mock.Candidate().Returns(42); + const string experimentName = nameof(ControlFirst_specified_should_run_first); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var result = scientist.Experiment(experimentName, experiment => + { + experiment.ThrowOnMismatches = true; + experiment.UseCustomOrdering(Ordering.ControlFirst); + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate); + }); + + result.Should().Be(42); + resultPublisher.Results(experimentName).First().Matched.Should().BeTrue(); + Received.InOrder(() => + { + mock.Received().Control(); + mock.Received().Candidate(); + }); + } + + [Fact] + public void ControlLast_specified_should_run_last() + { + var mock = Substitute.For>(); + mock.Control().Returns(42); + mock.Candidate().Returns(42); + const string experimentName = nameof(ControlLast_specified_should_run_last); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var result = scientist.Experiment(experimentName, experiment => + { + experiment.ThrowOnMismatches = true; + experiment.UseCustomOrdering(Ordering.ControlLast); + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate); + }); + + result.Should().Be(42); + resultPublisher.Results(experimentName).First().Matched.Should().BeTrue(); + Received.InOrder(() => + { + mock.Received().Candidate(); + mock.Received().Control(); + }); + } + + [Fact] + public void Passed_custom_ordering_method_runs() + { + var mock = Substitute.For>(); + mock.Control().Returns(42); + mock.Candidate().Returns(42); + const string experimentName = nameof(ControlLast_specified_should_run_last); + + var resultPublisher = new InMemoryResultPublisher(); + var scientist = new Scientist(resultPublisher); + + var result = scientist.Experiment(experimentName, experiment => + { + experiment.ThrowOnMismatches = true; + experiment.UseCustomOrdering(SeededExperimentOrderer); + experiment.Use(mock.Control); + experiment.Try("candidate", mock.Candidate); + }); + + result.Should().Be(42); + resultPublisher.Results(experimentName).First().Matched.Should().BeTrue(); + Received.InOrder(() => + { + mock.Received().Candidate(); + mock.Received().Control(); + }); + } + + private static int _seed = 123; + + public static IReadOnlyList> SeededExperimentOrderer(IReadOnlyList> behaviors) + { + var random = new Random(_seed); + return behaviors.OrderBy(_ => random.Next()).ToList(); + } +} + diff --git a/test/Scientist.Test/Scientist.Test.csproj b/test/Scientist.Test/Scientist.Test.csproj index 530c363..efa114f 100644 --- a/test/Scientist.Test/Scientist.Test.csproj +++ b/test/Scientist.Test/Scientist.Test.csproj @@ -21,8 +21,9 @@ PreserveNewest - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/Scientist.Test/packages.lock.json b/test/Scientist.Test/packages.lock.json index f4d397b..0497fb2 100644 --- a/test/Scientist.Test/packages.lock.json +++ b/test/Scientist.Test/packages.lock.json @@ -8,13 +8,22 @@ "resolved": "5.2.1", "contentHash": "wHARzQA695jwwKreOzNsq54KiGqKP38tv8hi8e2FXDEC/sA6BtrX90tVPDkOfVu13PbEzr00TCV8coikl+D1Iw==" }, + "FluentAssertions": { + "type": "Direct", + "requested": "[8.8.0, )", + "resolved": "8.8.0", + "contentHash": "m0kwcqBwvVel03FuMa7Ozo/oTaxYbjeNlcOhQFkyQpwX/8wks6RNl/Jnn58DCZVs6c2oG1RsCZw7HfKSaxLm3w==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", - "requested": "[17.14.1, )", - "resolved": "17.14.1", - "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "requested": "[18.0.1, )", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", "dependencies": { - "Microsoft.CodeCoverage": "17.14.1" + "Microsoft.CodeCoverage": "18.0.1" } }, "NSubstitute": { @@ -40,17 +49,17 @@ }, "xunit.runner.visualstudio": { "type": "Direct", - "requested": "[3.1.4, )", - "resolved": "3.1.4", - "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==", "dependencies": { "Microsoft.TestPlatform.ObjectModel": "17.13.0" } }, "Microsoft.CodeCoverage": { "type": "Transitive", - "resolved": "17.14.1", - "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", @@ -73,10 +82,18 @@ "System.Collections.Immutable": "1.5.0" } }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw==" + }, "System.Threading.Tasks.Extensions": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "npvJkVKl5rKXrtl1Kkm6OhOUaYGEiF9wFbppFRWSMoApKzt2PiPHT2Bb8a5sAWxprvdOAtvaARS9QYMznEUtug==" + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } }, "xunit.abstractions": { "type": "Transitive", @@ -132,14 +149,20 @@ "System.Diagnostics.EventLog": "6.0.0" } }, + "FluentAssertions": { + "type": "Direct", + "requested": "[8.8.0, )", + "resolved": "8.8.0", + "contentHash": "m0kwcqBwvVel03FuMa7Ozo/oTaxYbjeNlcOhQFkyQpwX/8wks6RNl/Jnn58DCZVs6c2oG1RsCZw7HfKSaxLm3w==" + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", - "requested": "[17.14.1, )", - "resolved": "17.14.1", - "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "requested": "[18.0.1, )", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", "dependencies": { - "Microsoft.CodeCoverage": "17.14.1", - "Microsoft.TestPlatform.TestHost": "17.14.1" + "Microsoft.CodeCoverage": "18.0.1", + "Microsoft.TestPlatform.TestHost": "18.0.1" } }, "NSubstitute": { @@ -164,29 +187,29 @@ }, "xunit.runner.visualstudio": { "type": "Direct", - "requested": "[3.1.4, )", - "resolved": "3.1.4", - "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" }, "Microsoft.CodeCoverage": { "type": "Transitive", - "resolved": "17.14.1", - "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", - "resolved": "17.14.1", - "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==", + "resolved": "18.0.1", + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==", "dependencies": { "System.Reflection.Metadata": "8.0.0" } }, "Microsoft.TestPlatform.TestHost": { "type": "Transitive", - "resolved": "17.14.1", - "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "resolved": "18.0.1", + "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Microsoft.TestPlatform.ObjectModel": "18.0.1", "Newtonsoft.Json": "13.0.3" } },