diff --git a/docs/api/create_docker_container.md b/docs/api/create_docker_container.md index 8dd663a34..24a885c80 100644 --- a/docs/api/create_docker_container.md +++ b/docs/api/create_docker_container.md @@ -100,6 +100,37 @@ _ = new ContainerBuilder() The static class `Consume` offers pre-configured implementations of the `IOutputConsumer` interface for common use cases. If you need additional functionalities beyond those provided by the default implementations, you can create your own implementations of `IOutputConsumer`. +## Composing command arguments + +Testcontainers for .NET provides the `WithCommand(ComposableEnumerable)` API to give you flexible control over container command arguments. While currently used for container commands, the `ComposableEnumerable` abstraction is designed to support other builder APIs in the future, allowing similar composition and override functionality. + +Because our builders are immutable, this feature allows you to extend or override pre-configured configurations, such as those in Testcontainers [modules](../modules/index.md), without modifying the original builder. + +`ComposableEnumerable` lets you decide how new API arguments should be combined with existing ones. You can choose to append, overwrite, or apply other strategies based on your needs. + +If a module applies default commands and you need to override or remove them entirely, you can do this e.g. by explicitly resetting the command list: + +```csharp title="Resetting command arguments" +// Default PostgreSQL builder configuration: +// +// base.Init() +// ... +// .WithCommand("-c", "fsync=off") +// .WithCommand("-c", "full_page_writes=off") +// .WithCommand("-c", "synchronous_commit=off") +// ... + +var postgreSqlContainer = new PostgreSqlBuilder() + .WithCommand(new OverwriteEnumerable(Array.Empty())) + .Build(); +``` + +Using `OverwriteEnumerable(Array.Empty())` removes all default command configurations. This is useful when you want full control over the PostgreSQL startup or when the default configurations do not match your requirements. + +!!!tip + + You can create your own `ComposableEnumerable` implementation to control exactly how configuration values are composed or modified. + ## Examples An NGINX container that binds the HTTP port to a random host port and hosts static content. The example connects to the web server and checks the HTTP status code. diff --git a/src/Testcontainers/Builders/BuildConfiguration.cs b/src/Testcontainers/Builders/BuildConfiguration.cs index 824008646..a4dcec675 100644 --- a/src/Testcontainers/Builders/BuildConfiguration.cs +++ b/src/Testcontainers/Builders/BuildConfiguration.cs @@ -4,29 +4,38 @@ namespace DotNet.Testcontainers.Builders using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; + using DotNet.Testcontainers.Configurations; + /// + /// Provides static utility methods for combining old and new configuration values + /// across various collection and value types. + /// public static class BuildConfiguration { /// - /// Returns the changed configuration object. If there is no change, the previous configuration object is returned. + /// Returns the updated configuration value. If the new value is null or + /// default, the old value is returned. /// - /// The old configuration object. - /// The new configuration object. + /// The old configuration value. + /// The new configuration value. /// Any class. - /// Changed configuration object. If there is no change, the previous configuration object. + /// The updated value, or the old value if unchanged. public static T Combine(T oldValue, T newValue) { return Equals(default(T), newValue) ? oldValue : newValue; } /// - /// Combines all existing and new configuration changes. If there are no changes, the previous configurations are returned. + /// Combines all existing and new configuration changes. If there are no changes, + /// the previous configurations are returned. /// /// The old configuration. /// The new configuration. - /// Type of . + /// The type of elements in the collection. /// An updated configuration. - public static IEnumerable Combine(IEnumerable oldValue, IEnumerable newValue) + public static IEnumerable Combine( + IEnumerable oldValue, + IEnumerable newValue) { if (newValue == null && oldValue == null) { @@ -42,14 +51,17 @@ public static IEnumerable Combine(IEnumerable oldValue, IEnumerable } /// - /// Combines all existing and new configuration changes while preserving the order of insertion. - /// If there are no changes, the previous configurations are returned. + /// Combines all existing and new configuration changes while preserving the + /// order of insertion. If there are no changes, the previous configurations + /// are returned. /// /// The old configuration. /// The new configuration. - /// Type of . + /// The type of elements in the collection. /// An updated configuration. - public static IReadOnlyList Combine(IReadOnlyList oldValue, IReadOnlyList newValue) + public static IReadOnlyList Combine( + IReadOnlyList oldValue, + IReadOnlyList newValue) { if (newValue == null && oldValue == null) { @@ -65,14 +77,51 @@ public static IReadOnlyList Combine(IReadOnlyList oldValue, IReadOnlyLi } /// - /// Combines all existing and new configuration changes. If there are no changes, the previous configurations are returned. + /// Combines all existing and new configuration changes. If there are no changes, + /// the previous configuration is returned. /// + /// + /// Uses on + /// to combine configurations. The existing is passed as + /// an argument to that method. + /// /// The old configuration. /// The new configuration. - /// The type of keys in the read-only dictionary. - /// The type of values in the read-only dictionary. + /// The type of elements in the collection. /// An updated configuration. - public static IReadOnlyDictionary Combine(IReadOnlyDictionary oldValue, IReadOnlyDictionary newValue) + public static ComposableEnumerable Combine( + ComposableEnumerable oldValue, + ComposableEnumerable newValue) + { + // Creating a new container configuration before merging will follow this branch + // and return the default value. If we use the overwrite implementation, + // merging will reset the collection, we should either return null or use + // the append implementation. + if (newValue == null && oldValue == null) + { + return new AppendEnumerable(Array.Empty()); + } + + if (newValue == null || oldValue == null) + { + return newValue ?? oldValue; + } + + return newValue.Compose(oldValue); + } + + /// + /// Combines all existing and new configuration changes. If there are no changes, + /// the previous configurations are returned. + /// + /// The old configuration. + /// The new configuration. + /// The type of keys in the dictionary. + /// The type of values in the dictionary. + /// An updated configuration. + public static IReadOnlyDictionary Combine( + IReadOnlyDictionary oldValue, + IReadOnlyDictionary newValue) { if (newValue == null && oldValue == null) { @@ -84,7 +133,19 @@ public static IReadOnlyDictionary Combine(IReadOnlyD return newValue ?? oldValue; } - return newValue.Concat(oldValue.Where(item => !newValue.Keys.Contains(item.Key))).ToDictionary(item => item.Key, item => item.Value); + var result = new Dictionary(oldValue.Count + newValue.Count); + + foreach (var kvp in oldValue) + { + result[kvp.Key] = kvp.Value; + } + + foreach (var kvp in newValue) + { + result[kvp.Key] = kvp.Value; + } + + return new ReadOnlyDictionary(result); } } } diff --git a/src/Testcontainers/Builders/ContainerBuilder`3.cs b/src/Testcontainers/Builders/ContainerBuilder`3.cs index 468c894c9..f508e7b51 100644 --- a/src/Testcontainers/Builders/ContainerBuilder`3.cs +++ b/src/Testcontainers/Builders/ContainerBuilder`3.cs @@ -133,6 +133,13 @@ public TBuilderEntity WithEntrypoint(params string[] entrypoint) /// public TBuilderEntity WithCommand(params string[] command) + { + var composable = new AppendEnumerable(command); + return WithCommand(composable); + } + + /// + public TBuilderEntity WithCommand(ComposableEnumerable command) { return Clone(new ContainerConfiguration(command: command)); } diff --git a/src/Testcontainers/Builders/IContainerBuilder`2.cs b/src/Testcontainers/Builders/IContainerBuilder`2.cs index 0493fcbe5..b5cbb905e 100644 --- a/src/Testcontainers/Builders/IContainerBuilder`2.cs +++ b/src/Testcontainers/Builders/IContainerBuilder`2.cs @@ -140,6 +140,17 @@ public interface IContainerBuilder : I [PublicAPI] TBuilderEntity WithCommand(params string[] command); + /// + /// Overrides the container's command arguments. + /// + /// + /// The allows to choose how existing builder configurations are composed. + /// + /// A list of commands, "executable", "param1", "param2" or "param1", "param2". + /// A configured instance of . + [PublicAPI] + TBuilderEntity WithCommand(ComposableEnumerable command); + /// /// Sets the environment variable. /// diff --git a/src/Testcontainers/Configurations/Commons/AppendDictionary`2.cs b/src/Testcontainers/Configurations/Commons/AppendDictionary`2.cs new file mode 100644 index 000000000..f20f1cb69 --- /dev/null +++ b/src/Testcontainers/Configurations/Commons/AppendDictionary`2.cs @@ -0,0 +1,31 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System.Collections.Generic; + using JetBrains.Annotations; + using DotNet.Testcontainers.Builders; + + /// + /// Represents a composable dictionary that combines its elements by appending + /// the elements of another dictionary with overwriting existing keys. + /// + /// The type of keys in the dictionary. + /// The type of values in the dictionary. + [PublicAPI] + public sealed class AppendDictionary : ComposableDictionary + { + /// + /// Initializes a new instance of the class. + /// + /// The dictionary whose elements are copied to the new dictionary. + public AppendDictionary(IReadOnlyDictionary dictionary) + : base(dictionary) + { + } + + /// + public override ComposableDictionary Compose(IReadOnlyDictionary other) + { + return new AppendDictionary(BuildConfiguration.Combine(other, this)); + } + } +} diff --git a/src/Testcontainers/Configurations/Commons/AppendEnumerable`1.cs b/src/Testcontainers/Configurations/Commons/AppendEnumerable`1.cs new file mode 100644 index 000000000..49c94c189 --- /dev/null +++ b/src/Testcontainers/Configurations/Commons/AppendEnumerable`1.cs @@ -0,0 +1,30 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System.Collections.Generic; + using JetBrains.Annotations; + using DotNet.Testcontainers.Builders; + + /// + /// Represents a composable collection that combines its elements by appending + /// the elements of another collection. + /// + /// The type of elements in the collection. + [PublicAPI] + public sealed class AppendEnumerable : ComposableEnumerable + { + /// + /// Initializes a new instance of the class. + /// + /// The collection of items. If null, an empty collection is used. + public AppendEnumerable(IEnumerable collection) + : base(collection) + { + } + + /// + public override ComposableEnumerable Compose(IEnumerable other) + { + return new AppendEnumerable(BuildConfiguration.Combine(other, this)); + } + } +} diff --git a/src/Testcontainers/Configurations/Commons/ComposableDictionary`2.cs b/src/Testcontainers/Configurations/Commons/ComposableDictionary`2.cs new file mode 100644 index 000000000..f677e6097 --- /dev/null +++ b/src/Testcontainers/Configurations/Commons/ComposableDictionary`2.cs @@ -0,0 +1,66 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System.Collections; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using JetBrains.Annotations; + + /// + /// Represents an immutable dictionary that defines a custom strategy for + /// composing its elements with those of another dictionary. This class is + /// intended to be inherited by implementations that specify how two dictionaries + /// should be combined. + /// + /// The type of keys in the dictionary. + /// The type of values in the dictionary. + [PublicAPI] + public abstract class ComposableDictionary : IReadOnlyDictionary + { + private readonly IReadOnlyDictionary _dictionary; + + /// + /// Initializes a new instance of the class. + /// + /// The dictionary of items. If null, an empty dictionary is used. + protected ComposableDictionary(IReadOnlyDictionary dictionary) + { + _dictionary = dictionary ?? new ReadOnlyDictionary(new Dictionary()); + } + + /// + /// Combines the current dictionary with the specified dictionary according to + /// the composition strategy defined by the class. + /// + /// + /// The parameter corresponds to the previous builder + /// configuration. + /// + /// The incoming dictionary to compose with this dictionary. + /// A new that contains the result of the composition. + public abstract ComposableDictionary Compose([NotNull] IReadOnlyDictionary other); + + /// + public IEnumerable Keys => _dictionary.Keys; + + /// + public IEnumerable Values => _dictionary.Values; + + /// + public int Count => _dictionary.Count; + + /// + public TValue this[TKey key] => _dictionary[key]; + + /// + public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key); + + /// + public bool TryGetValue(TKey key, out TValue value) => _dictionary.TryGetValue(key, out value); + + /// + public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Testcontainers/Configurations/Commons/ComposableEnumerable`1.cs b/src/Testcontainers/Configurations/Commons/ComposableEnumerable`1.cs new file mode 100644 index 000000000..2f3c898b4 --- /dev/null +++ b/src/Testcontainers/Configurations/Commons/ComposableEnumerable`1.cs @@ -0,0 +1,53 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System; + using System.Collections; + using System.Collections.Generic; + using JetBrains.Annotations; + + /// + /// Represents an immutable collection that defines a custom strategy for + /// composing its elements with those of another collection. This class is + /// intended to be inherited by implementations that specify how two collections + /// should be combined. + /// + /// The type of the elements in the collection. + [PublicAPI] + public abstract class ComposableEnumerable : IEnumerable + { + private readonly IEnumerable _collection; + + /// + /// Initializes a new instance of the class. + /// + /// The collection of items. If null, an empty collection is used. + protected ComposableEnumerable(IEnumerable collection) + { + _collection = collection ?? Array.Empty(); + } + + /// + /// Combines the current collection with the specified collection according to + /// the composition strategy defined by the class. + /// + /// + /// The parameter corresponds to the previous builder + /// configuration. + /// + /// The incoming collection to compose with this collection. + /// A new that contains the result of the composition. + public abstract ComposableEnumerable Compose([NotNull] IEnumerable other); + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// An enumerator for the current collection. + public IEnumerator GetEnumerator() => _collection.GetEnumerator(); + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// An enumerator for the current collection. + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Testcontainers/Configurations/Commons/OverwriteDictionary`2.cs b/src/Testcontainers/Configurations/Commons/OverwriteDictionary`2.cs new file mode 100644 index 000000000..cd0631ebf --- /dev/null +++ b/src/Testcontainers/Configurations/Commons/OverwriteDictionary`2.cs @@ -0,0 +1,31 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System.Collections.Generic; + using JetBrains.Annotations; + + /// + /// Represents a composable dictionary that combines its elements by replacing + /// the current dictionary with the elements of another dictionary. + /// + /// The type of keys in the dictionary. + /// The type of values in the dictionary. + [PublicAPI] + public sealed class OverwriteDictionary : ComposableDictionary + { + /// + /// Initializes a new instance of the class. + /// + /// The dictionary whose elements are copied to the new dictionary. + public OverwriteDictionary(IReadOnlyDictionary dictionary) + : base(dictionary) + { + } + + /// + public override ComposableDictionary Compose(IReadOnlyDictionary other) + { + // Ignores all previous configurations. + return this; + } + } +} diff --git a/src/Testcontainers/Configurations/Commons/OverwriteEnumerable`1.cs b/src/Testcontainers/Configurations/Commons/OverwriteEnumerable`1.cs new file mode 100644 index 000000000..7b7ed1b01 --- /dev/null +++ b/src/Testcontainers/Configurations/Commons/OverwriteEnumerable`1.cs @@ -0,0 +1,30 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System.Collections.Generic; + using JetBrains.Annotations; + + /// + /// Represents a composable collection that combines its elements by replacing + /// the current collection with the elements of another collection. + /// + /// The type of elements in the collection. + [PublicAPI] + public sealed class OverwriteEnumerable : ComposableEnumerable + { + /// + /// Initializes a new instance of the class. + /// + /// The collection of items. If null, an empty collection is used. + public OverwriteEnumerable(IEnumerable collection) + : base(collection) + { + } + + /// + public override ComposableEnumerable Compose(IEnumerable other) + { + // Ignores all previous configurations. + return this; + } + } +} diff --git a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs index b2a26a0d2..4a3c2b4aa 100644 --- a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs @@ -49,7 +49,7 @@ public ContainerConfiguration( string macAddress = null, string workingDirectory = null, IEnumerable entrypoint = null, - IEnumerable command = null, + ComposableEnumerable command = null, IReadOnlyDictionary environments = null, IReadOnlyDictionary exposedPorts = null, IReadOnlyDictionary portBindings = null, @@ -173,7 +173,7 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public IEnumerable Entrypoint { get; } /// - public IEnumerable Command { get; } + public ComposableEnumerable Command { get; } /// public IReadOnlyDictionary Environments { get; } diff --git a/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs index 8966f48d3..b62ba5e85 100644 --- a/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs @@ -64,7 +64,7 @@ public interface IContainerConfiguration : IResourceConfiguration /// Gets the command. /// - IEnumerable Command { get; } + ComposableEnumerable Command { get; } /// /// Gets a dictionary of environment variables. diff --git a/tests/Testcontainers.Platform.Linux.Tests/ComposableTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ComposableTest.cs new file mode 100644 index 000000000..638e8001c --- /dev/null +++ b/tests/Testcontainers.Platform.Linux.Tests/ComposableTest.cs @@ -0,0 +1,70 @@ +namespace Testcontainers.Tests; + +public sealed class ComposableTest +{ + private static readonly ComposableEnumerable AppendListFlag = + new AppendEnumerable(new[] { "-l" }); + + private static readonly ComposableEnumerable OverwriteWithSearchArg = + new OverwriteEnumerable(new[] { "pattern", "*.log" }); + + [Fact] + public void AppendsFlagAfterInitialArgument() + { + var command = new ComposableContainerBuilder() + .WithCommand("foo") + .WithCommand(AppendListFlag) + .GetCommand(); + + Assert.Equal(new[] { "foo", "-l" }, command); + } + + [Fact] + public void OverwritesArgumentCompletely() + { + var command = new ComposableContainerBuilder() + .WithCommand("foo") + .WithCommand(OverwriteWithSearchArg) + .GetCommand(); + + Assert.Equal(new[] { "pattern", "*.log" }, command); + } + + [Fact] + public void OverwritesThenAppendsFlag() + { + var command = new ComposableContainerBuilder() + .WithCommand("foo") + .WithCommand(OverwriteWithSearchArg) + .WithCommand(AppendListFlag) + .GetCommand(); + + Assert.Equal(new[] { "pattern", "*.log", "-l" }, command); + } + + private sealed class ComposableContainerBuilder : ContainerBuilder + { + public ComposableContainerBuilder() : this(new ContainerConfiguration()) + => DockerResourceConfiguration = Init().DockerResourceConfiguration; + + private ComposableContainerBuilder(ContainerConfiguration configuration) : base(configuration) + => DockerResourceConfiguration = configuration; + + protected override ContainerConfiguration DockerResourceConfiguration { get; } + + public IEnumerable GetCommand() + => DockerResourceConfiguration.Command; + + public override DockerContainer Build() + => new(DockerResourceConfiguration); + + protected override ComposableContainerBuilder Clone(IResourceConfiguration resourceConfiguration) + => Merge(DockerResourceConfiguration, new ContainerConfiguration(resourceConfiguration)); + + protected override ComposableContainerBuilder Clone(IContainerConfiguration resourceConfiguration) + => Merge(DockerResourceConfiguration, new ContainerConfiguration(resourceConfiguration)); + + protected override ComposableContainerBuilder Merge(ContainerConfiguration oldValue, ContainerConfiguration newValue) + => new(new ContainerConfiguration(oldValue, newValue)); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Tests/Unit/Builders/BuildConfigurationTest.cs b/tests/Testcontainers.Tests/Unit/Builders/BuildConfigurationTest.cs index 00e83b2bc..09b776285 100644 --- a/tests/Testcontainers.Tests/Unit/Builders/BuildConfigurationTest.cs +++ b/tests/Testcontainers.Tests/Unit/Builders/BuildConfigurationTest.cs @@ -2,9 +2,9 @@ namespace DotNet.Testcontainers.Tests.Unit { using System; using System.Collections.Generic; - using System.Collections.ObjectModel; using System.Linq; using DotNet.Testcontainers.Builders; + using DotNet.Testcontainers.Configurations; using Xunit; public sealed class BuildConfigurationTest @@ -14,7 +14,10 @@ public sealed class BuildConfigurationTest [InlineData(null, "B", "B")] [InlineData("A", null, "A")] [InlineData("A", "B", "B")] - public void CombineReferenceTypes(string oldValue, string newValue, string expected) + public void CombineReferenceTypes( + string oldValue, + string newValue, + string expected) { var actual = BuildConfiguration.Combine(oldValue, newValue); Assert.Equal(expected, actual); @@ -22,7 +25,10 @@ public void CombineReferenceTypes(string oldValue, string newValue, string expec [Theory] [ClassData(typeof(EnumerableCombinationTestData))] - public void CombineEnumerables(IEnumerable oldValue, IEnumerable newValue, IEnumerable expected) + public void CombineEnumerables( + IEnumerable oldValue, + IEnumerable newValue, + IEnumerable expected) { var actual = BuildConfiguration.Combine(oldValue, newValue); Assert.Equal(expected?.OrderBy(item => item), actual?.OrderBy(item => item)); @@ -30,7 +36,10 @@ public void CombineEnumerables(IEnumerable oldValue, IEnumerable [Theory] [ClassData(typeof(ReadOnlyListCombinationTestData))] - public void CombineReadOnlyLists(IReadOnlyList oldValue, IReadOnlyList newValue, IReadOnlyList expected) + public void CombineReadOnlyLists( + IReadOnlyList oldValue, + IReadOnlyList newValue, + IReadOnlyList expected) { var actual = BuildConfiguration.Combine(oldValue, newValue); Assert.Equal(expected, actual); @@ -38,44 +47,347 @@ public void CombineReadOnlyLists(IReadOnlyList oldValue, IReadOnlyList oldValue, IReadOnlyDictionary newValue, IReadOnlyDictionary expected) + public void CombineReadOnlyDictionaries( + IReadOnlyDictionary oldValue, + IReadOnlyDictionary newValue, + IReadOnlyDictionary expected) { var actual = BuildConfiguration.Combine(oldValue, newValue); Assert.Equal(expected, actual); } - private sealed class EnumerableCombinationTestData : List + [Theory] + [ClassData(typeof(ComposableEnumerableCombinationTestData))] + public void CombineComposableEnumerables( + ComposableEnumerable oldValue, + ComposableEnumerable newValue, + IEnumerable expected) + { + var actual = BuildConfiguration.Combine(oldValue, newValue); + Assert.Equal(expected, actual); + } + + [Theory] + [ClassData(typeof(AppendEnumerableTestData))] + public void AppendEnumerableCompose( + IEnumerable oldValue, + IEnumerable newValue, + IEnumerable expected) + { + var append = new AppendEnumerable(newValue); + var result = append.Compose(oldValue); + Assert.Equal(expected, result); + } + + [Theory] + [ClassData(typeof(OverwriteEnumerableTestData))] + public void OverwriteEnumerableCompose( + IEnumerable oldValue, + IEnumerable newValue, + IEnumerable expected) + { + var overwrite = new OverwriteEnumerable(newValue); + var result = overwrite.Compose(oldValue); + Assert.Equal(expected, result); + } + + [Theory] + [ClassData(typeof(AppendDictionaryTestData))] + public void AppendDictionaryCompose( + IReadOnlyDictionary oldValue, + IReadOnlyDictionary newValue, + IReadOnlyDictionary expected) + { + var append = new AppendDictionary(newValue); + var result = append.Compose(oldValue); + Assert.Equal(expected, result); + } + + [Theory] + [ClassData(typeof(OverwriteDictionaryTestData))] + public void OverwriteDictionaryCompose( + IReadOnlyDictionary oldValue, + IReadOnlyDictionary newValue, + IReadOnlyDictionary expected) + { + var overwrite = new OverwriteDictionary(newValue); + var result = overwrite.Compose(oldValue); + Assert.Equal(expected, result); + } + + [Fact] + public void ComposableEnumerableHandlesNullCollection() + { + var append = new AppendEnumerable(null); + Assert.Empty(append); + + var overwrite = new OverwriteEnumerable(null); + Assert.Empty(overwrite); + } + + [Fact] + public void ComposableDictionaryHandlesNullDictionary() + { + var append = new AppendDictionary(null); + Assert.Empty(append); + + var overwrite = new OverwriteDictionary(null); + Assert.Empty(overwrite); + } + + private sealed class EnumerableCombinationTestData + : TheoryData< + IEnumerable, + IEnumerable, + IEnumerable + > { public EnumerableCombinationTestData() { - Add(new object[] { null, null, Array.Empty() }); - Add(new object[] { null, new[] { "2" }, new[] { "2" } }); - Add(new object[] { new[] { "1" }, null, new[] { "1" } }); - Add(new object[] { new[] { "1" }, new[] { "2" }, new[] { "1", "2" } }); - Add(new object[] { new[] { "1", "2", "3" }, new[] { "2", "3", "4" }, new[] { "1", "2", "2", "3", "3", "4" } }); + Add(null, + null, + Array.Empty()); + + Add(null, + new[] { "2" }, + new[] { "2" }); + + Add(new[] { "1" }, + null, + new[] { "1" }); + + Add(new[] { "1" }, + new[] { "2" }, + new[] { "1", "2" }); + + Add(new[] { "1", "2", "3" }, + new[] { "2", "3", "4" }, + new[] { "1", "2", "2", "3", "3", "4" }); } } - private sealed class ReadOnlyListCombinationTestData : List + private sealed class ReadOnlyListCombinationTestData + : TheoryData< + IReadOnlyList, + IReadOnlyList, + IReadOnlyList + > { public ReadOnlyListCombinationTestData() { - Add(new object[] { null, null, Array.Empty() }); - Add(new object[] { null, new[] { "2" }, new[] { "2" } }); - Add(new object[] { new[] { "1" }, null, new[] { "1" } }); - Add(new object[] { new[] { "1" }, new[] { "2" }, new[] { "1", "2" } }); - Add(new object[] { new[] { "1", "2", "3" }, new[] { "2", "3", "4" }, new[] { "1", "2", "3", "2", "3", "4" } }); + Add(null, + null, + Array.Empty()); + + Add(null, + new[] { "2" }, + new[] { "2" }); + + Add(new[] { "1" }, + null, + new[] { "1" }); + + Add(new[] { "1" }, + new[] { "2" }, + new[] { "1", "2" }); + + Add(new[] { "1", "2", "3" }, + new[] { "2", "3", "4" }, + new[] { "1", "2", "3", "2", "3", "4" }); } } - private sealed class DictionaryCombinationTestData : List + private sealed class DictionaryCombinationTestData + : TheoryData< + IReadOnlyDictionary, + IReadOnlyDictionary, + IReadOnlyDictionary + > { public DictionaryCombinationTestData() { - Add(new object[] { null, null, new ReadOnlyDictionary(new Dictionary()) }); - Add(new object[] { new Dictionary { { "A", "A" } }, null, new Dictionary { { "A", "A" } } }); - Add(new object[] { null, new Dictionary { { "B", "B" } }, new Dictionary { { "B", "B" } } }); - Add(new object[] { new Dictionary { ["A"] = "old", ["B"] = "B" }, new Dictionary { ["A"] = "new" }, new Dictionary { ["A"] = "new", ["B"] = "B" } }); + Add(null, + null, + new Dictionary()); + + Add(new Dictionary { ["A"] = "A" }, + null, + new Dictionary { ["A"] = "A" }); + + Add(null, + new Dictionary { ["B"] = "B" }, + new Dictionary { ["B"] = "B" }); + + Add(new Dictionary { ["A"] = "old", ["B"] = "B" }, + new Dictionary { ["A"] = "new" }, + new Dictionary { ["A"] = "new", ["B"] = "B" }); + } + } + + private sealed class ComposableEnumerableCombinationTestData + : TheoryData< + ComposableEnumerable, + ComposableEnumerable, + IEnumerable + > + { + public ComposableEnumerableCombinationTestData() + { + Add(null, + null, + Array.Empty()); + + Add(null, + new AppendEnumerable(new[] { "2" }), + new[] { "2" }); + + Add(new AppendEnumerable(new[] { "1" }), + null, + new[] { "1" }); + + Add(new AppendEnumerable(new[] { "1" }), + new AppendEnumerable(new[] { "2" }), + new[] { "1", "2" }); + + Add(new AppendEnumerable(new[] { "1", "2" }), + new OverwriteEnumerable(new[] { "3", "4" }), + new[] { "3", "4" }); + + Add(new AppendEnumerable(new[] { "1", "2", "3" }), + new AppendEnumerable(new[] { "4", "5" }), + new[] { "1", "2", "3", "4", "5" }); + } + } + + private sealed class AppendEnumerableTestData + : TheoryData< + IEnumerable, + IEnumerable, + IEnumerable + > + { + public AppendEnumerableTestData() + { + Add(Array.Empty(), + Array.Empty(), + Array.Empty()); + + Add(new[] { "old" }, + Array.Empty(), + new[] { "old" }); + + Add(Array.Empty(), + new[] { "new" }, + new[] { "new" }); + + Add(new[] { "old" }, + new[] { "new" }, + new[] { "old", "new" }); + + Add(new[] { "1", "2" }, + new[] { "3", "4" }, + new[] { "1", "2", "3", "4" }); + + Add(new[] { "A", "B", "C" }, + new[] { "X", "Y" }, + new[] { "A", "B", "C", "X", "Y" }); + } + } + + private sealed class OverwriteEnumerableTestData + : TheoryData< + IEnumerable, + IEnumerable, + IEnumerable + > + { + public OverwriteEnumerableTestData() + { + Add(Array.Empty(), + Array.Empty(), + Array.Empty()); + + Add(new[] { "old" }, + Array.Empty(), + Array.Empty()); + + Add(Array.Empty(), + new[] { "new" }, + new[] { "new" }); + + Add(new[] { "old" }, + new[] { "new" }, + new[] { "new" }); + + Add(new[] { "1", "2" }, + new[] { "3", "4" }, + new[] { "3", "4" }); + + Add(new[] { "A", "B", "C" }, + new[] { "X", "Y", "Z" }, + new[] { "X", "Y", "Z" }); + } + } + + private sealed class AppendDictionaryTestData + : TheoryData< + IReadOnlyDictionary, + IReadOnlyDictionary, + IReadOnlyDictionary + > + { + public AppendDictionaryTestData() + { + Add(new Dictionary(), + new Dictionary(), + new Dictionary()); + + Add(new Dictionary { ["A"] = "old" }, + new Dictionary(), + new Dictionary { ["A"] = "old" }); + + Add(new Dictionary(), + new Dictionary { ["B"] = "new" }, + new Dictionary { ["B"] = "new" }); + + Add(new Dictionary { ["A"] = "old" }, + new Dictionary { ["B"] = "new" }, + new Dictionary { ["A"] = "old", ["B"] = "new" }); + + Add(new Dictionary { ["A"] = "old", ["B"] = "keep" }, + new Dictionary { ["A"] = "new" }, + new Dictionary { ["A"] = "new", ["B"] = "keep" }); + } + } + + private sealed class OverwriteDictionaryTestData + : TheoryData< + IReadOnlyDictionary, + IReadOnlyDictionary, + IReadOnlyDictionary + > + { + public OverwriteDictionaryTestData() + { + Add(new Dictionary(), + new Dictionary(), + new Dictionary()); + + Add(new Dictionary { ["A"] = "old" }, + new Dictionary(), + new Dictionary()); + + Add(new Dictionary(), + new Dictionary { ["B"] = "new" }, + new Dictionary { ["B"] = "new" }); + + Add(new Dictionary { ["A"] = "old" }, + new Dictionary { ["B"] = "new" }, + new Dictionary { ["B"] = "new" }); + + Add(new Dictionary { ["A"] = "old", ["B"] = "ignore" }, + new Dictionary { ["A"] = "new", ["C"] = "keep" }, + new Dictionary { ["A"] = "new", ["C"] = "keep" }); } } }