diff --git a/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs b/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs index ff47bf7603..7bde432762 100644 --- a/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs +++ b/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs @@ -27,7 +27,8 @@ public DependencyWaiter( /// public async Task WaitForDependenciesAsync(ModuleState moduleState, IModuleScheduler scheduler, IServiceProvider scopedServiceProvider) { - var dependencies = ModuleDependencyResolver.GetDependencies(moduleState.ModuleType); + // Get both attribute-based and programmatic dependencies + var dependencies = GetAllDependencies(moduleState); foreach (var (dependencyType, ignoreIfNotRegistered) in dependencies) { @@ -54,11 +55,29 @@ public async Task WaitForDependenciesAsync(ModuleState moduleState, IModuleSched $"but '{dependencyType.Name}' has not been registered in the pipeline.\n\n" + $"Suggestions:\n" + $" 1. Add '.AddModule<{dependencyType.Name}>()' to your pipeline configuration before '.AddModule<{moduleState.ModuleType.Name}>()'\n" + - $" 2. Use '[DependsOn<{dependencyType.Name}>(ignoreIfNotRegistered: true)]' if this dependency is optional\n" + + $" 2. Use 'deps.DependsOnOptional<{dependencyType.Name}>()' or '[DependsOn<{dependencyType.Name}>(IgnoreIfNotRegistered = true)]' if this dependency is optional\n" + $" 3. Check for typos in the dependency type name\n" + $" 4. Verify that '{dependencyType.Name}' is in a project referenced by your pipeline project"; throw new ModuleNotRegisteredException(message, null); } } } + + /// + /// Gets all dependencies for a module, combining attribute-based and programmatic dependencies. + /// + private static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetAllDependencies(ModuleState moduleState) + { + // Attribute-based dependencies + foreach (var dep in ModuleDependencyResolver.GetDependencies(moduleState.ModuleType)) + { + yield return dep; + } + + // Programmatic dependencies from DeclareDependencies method + foreach (var dep in ModuleDependencyResolver.GetProgrammaticDependencies(moduleState.Module)) + { + yield return dep; + } + } } diff --git a/src/ModularPipelines/Engine/ModuleDependencyResolver.cs b/src/ModularPipelines/Engine/ModuleDependencyResolver.cs index 781216d491..16fd7015fb 100644 --- a/src/ModularPipelines/Engine/ModuleDependencyResolver.cs +++ b/src/ModularPipelines/Engine/ModuleDependencyResolver.cs @@ -1,16 +1,24 @@ +using System.Collections.Concurrent; using System.Reflection; using ModularPipelines.Attributes; using ModularPipelines.Engine.Dependencies; +using ModularPipelines.Enums; using ModularPipelines.Extensions; +using ModularPipelines.Models; using ModularPipelines.Modules; namespace ModularPipelines.Engine; /// -/// Resolves module dependencies by inspecting DependsOn attributes. +/// Resolves module dependencies by inspecting DependsOn attributes and programmatic declarations. /// internal static class ModuleDependencyResolver { + /// + /// Cache for GetDeclaredDependencies method lookups to avoid repeated reflection. + /// + private static readonly ConcurrentDictionary GetDeclaredDependenciesMethodCache = new(); + /// /// Gets all dependencies declared on a module type via DependsOn attributes. /// This overload only handles DependsOnAttribute, not DependsOnAllModulesInheritingFromAttribute. @@ -65,6 +73,37 @@ internal static class ModuleDependencyResolver return GetDependencies(module.GetType()); } + /// + /// Gets programmatic dependencies declared via DeclareDependencies method on a module instance. + /// + /// The module instance to get programmatic dependencies from. + /// An enumerable of dependency tuples (DependencyType, IgnoreIfNotRegistered). + public static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetProgrammaticDependencies(IModule module) + { + // Use cached reflection lookup for GetDeclaredDependencies method + var moduleType = module.GetType(); + var method = GetDeclaredDependenciesMethodCache.GetOrAdd(moduleType, type => + type.GetMethod("GetDeclaredDependencies", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)); + + if (method == null) + { + yield break; + } + + var dependencies = method.Invoke(module, null) as IReadOnlyList; + + if (dependencies == null) + { + yield break; + } + + foreach (var dep in dependencies) + { + yield return (dep.ModuleType, dep.IgnoreIfNotRegistered); + } + } + /// /// Gets all dependencies declared on a module type via DependsOn attributes, /// including both static (attribute-based) and dynamic (runtime-added) dependencies. @@ -89,4 +128,39 @@ internal static class ModuleDependencyResolver } } } + + /// + /// Gets all dependencies declared on a module instance, including: + /// - Static dependencies from DependsOn attributes + /// - Programmatic dependencies from DeclareDependencies method + /// - Dynamic dependencies from the registry + /// + public static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetAllDependencies( + IModule module, + IEnumerable availableModuleTypes, + IModuleDependencyRegistry? dynamicRegistry = null) + { + var moduleType = module.GetType(); + + // Static dependencies from attributes + foreach (var dep in GetDependencies(moduleType, availableModuleTypes)) + { + yield return dep; + } + + // Programmatic dependencies from DeclareDependencies method + foreach (var dep in GetProgrammaticDependencies(module)) + { + yield return dep; + } + + // Dynamic dependencies from registration + if (dynamicRegistry != null) + { + foreach (var dynamicDep in dynamicRegistry.GetDynamicDependencies(moduleType)) + { + yield return (dynamicDep, false); + } + } + } } diff --git a/src/ModularPipelines/Engine/ModuleScheduler.cs b/src/ModularPipelines/Engine/ModuleScheduler.cs index efd9dbe220..9002daf955 100644 --- a/src/ModularPipelines/Engine/ModuleScheduler.cs +++ b/src/ModularPipelines/Engine/ModuleScheduler.cs @@ -155,7 +155,8 @@ public void InitializeModules(IEnumerable modules) } // Use the overload that includes dynamic dependencies from registration events - var dependencies = ModuleDependencyResolver.GetAllDependencies(moduleType, availableModuleTypes, _dependencyRegistry); + // and programmatic dependencies from DeclareDependencies method + var dependencies = ModuleDependencyResolver.GetAllDependencies(state.Module, availableModuleTypes, _dependencyRegistry); foreach (var (dependencyType, ignoreIfNotRegistered) in dependencies) { @@ -202,7 +203,8 @@ public void AddModule(IModule module) { // Resolve dependencies inside write lock to prevent race conditions // where _moduleStates could change between resolution and processing - var dependencies = ModuleDependencyResolver.GetDependencies(moduleType); + var dependencies = ModuleDependencyResolver.GetDependencies(moduleType) + .Concat(ModuleDependencyResolver.GetProgrammaticDependencies(module)); foreach (var (dependencyType, ignoreIfNotRegistered) in dependencies) { diff --git a/src/ModularPipelines/Enums/DependencyType.cs b/src/ModularPipelines/Enums/DependencyType.cs new file mode 100644 index 0000000000..6b9b05f098 --- /dev/null +++ b/src/ModularPipelines/Enums/DependencyType.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace ModularPipelines.Enums; + +/// +/// Defines the type of dependency between modules. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DependencyType +{ + /// + /// Required dependency. The dependent module will fail if this dependency is not registered. + /// + Required = 0, + + /// + /// Optional dependency. The dependent module will not fail if this dependency is not registered. + /// + Optional = 1, + + /// + /// Lazy dependency. Behaves the same as for dependency resolution purposes - + /// the dependency is optional and will not fail if not registered. This is a semantic marker to + /// indicate intent that the dependency may be awaited on-demand rather than required upfront. + /// + Lazy = 2, + + /// + /// Conditional dependency. Used when a dependency is added based on a runtime condition via + /// . For dependency resolution, + /// this behaves as a required dependency (will fail if condition is true and dependency is not registered). + /// + Conditional = 3, +} diff --git a/src/ModularPipelines/Models/DeclaredDependency.cs b/src/ModularPipelines/Models/DeclaredDependency.cs new file mode 100644 index 0000000000..d443da11dc --- /dev/null +++ b/src/ModularPipelines/Models/DeclaredDependency.cs @@ -0,0 +1,39 @@ +using ModularPipelines.Enums; + +namespace ModularPipelines.Models; + +/// +/// Represents a dependency declared programmatically via . +/// +/// The type of the module being depended on. +/// The kind of dependency (Required, Optional, Lazy, Conditional). +/// Whether to ignore this dependency if not registered. +public readonly record struct DeclaredDependency( + Type ModuleType, + DependencyType Kind, + bool IgnoreIfNotRegistered) +{ + /// + /// Creates a required dependency. + /// + public static DeclaredDependency Required(Type type) => + new(type, Enums.DependencyType.Required, false); + + /// + /// Creates an optional dependency. + /// + public static DeclaredDependency Optional(Type type) => + new(type, Enums.DependencyType.Optional, true); + + /// + /// Creates a lazy dependency. + /// + public static DeclaredDependency Lazy(Type type) => + new(type, Enums.DependencyType.Lazy, true); + + /// + /// Creates a conditional dependency. + /// + public static DeclaredDependency Conditional(Type type) => + new(type, Enums.DependencyType.Conditional, false); +} diff --git a/src/ModularPipelines/Modules/DependencyDeclaration.cs b/src/ModularPipelines/Modules/DependencyDeclaration.cs new file mode 100644 index 0000000000..bb71374b4d --- /dev/null +++ b/src/ModularPipelines/Modules/DependencyDeclaration.cs @@ -0,0 +1,111 @@ +using ModularPipelines.Enums; +using ModularPipelines.Models; + +namespace ModularPipelines.Modules; + +/// +/// Implementation of for collecting programmatic dependencies. +/// +/// +/// +/// Thread Safety: This class is not thread-safe. It is designed to be used +/// during the synchronous method call +/// and should not be accessed from multiple threads. +/// +/// +internal sealed class DependencyDeclaration : IDependencyDeclaration +{ + private readonly List _dependencies = new(); + + /// + /// Gets the declared dependencies. + /// + public IReadOnlyList Dependencies => _dependencies; + + /// + public IDependencyDeclaration DependsOn() where TModule : IModule + { + return DependsOn(typeof(TModule)); + } + + /// + public IDependencyDeclaration DependsOn(Type moduleType) + { + ValidateModuleType(moduleType); + _dependencies.Add(DeclaredDependency.Required(moduleType)); + return this; + } + + /// + public IDependencyDeclaration DependsOnOptional() where TModule : IModule + { + return DependsOnOptional(typeof(TModule)); + } + + /// + public IDependencyDeclaration DependsOnOptional(Type moduleType) + { + ValidateModuleType(moduleType); + _dependencies.Add(DeclaredDependency.Optional(moduleType)); + return this; + } + + /// + public IDependencyDeclaration DependsOnIf(bool condition) where TModule : IModule + { + return DependsOnIf(typeof(TModule), condition); + } + + /// + public IDependencyDeclaration DependsOnIf(Func predicate) where TModule : IModule + { + ArgumentNullException.ThrowIfNull(predicate); + return DependsOnIf(typeof(TModule), predicate()); + } + + /// + public IDependencyDeclaration DependsOnIf(Type moduleType, bool condition) + { + if (!condition) + { + return this; + } + + ValidateModuleType(moduleType); + _dependencies.Add(DeclaredDependency.Conditional(moduleType)); + return this; + } + + /// + public IDependencyDeclaration DependsOnIf(Type moduleType, Func predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + return DependsOnIf(moduleType, predicate()); + } + + /// + public IDependencyDeclaration DependsOnLazy() where TModule : IModule + { + return DependsOnLazy(typeof(TModule)); + } + + /// + public IDependencyDeclaration DependsOnLazy(Type moduleType) + { + ValidateModuleType(moduleType); + _dependencies.Add(DeclaredDependency.Lazy(moduleType)); + return this; + } + + private static void ValidateModuleType(Type moduleType) + { + ArgumentNullException.ThrowIfNull(moduleType); + + if (!moduleType.IsAssignableTo(typeof(IModule))) + { + throw new ArgumentException( + $"{moduleType.FullName} is not a Module (does not implement IModule)", + nameof(moduleType)); + } + } +} diff --git a/src/ModularPipelines/Modules/IDependencyDeclaration.cs b/src/ModularPipelines/Modules/IDependencyDeclaration.cs new file mode 100644 index 0000000000..2842d1e3a2 --- /dev/null +++ b/src/ModularPipelines/Modules/IDependencyDeclaration.cs @@ -0,0 +1,167 @@ +namespace ModularPipelines.Modules; + +/// +/// Interface for declaring module dependencies programmatically at runtime. +/// +/// +/// +/// This interface provides methods for declaring dependencies dynamically, complementing +/// the static approach. +/// +/// +/// Dependencies declared via this interface are combined with attribute-based dependencies. +/// +/// +/// +/// +/// public class ApiModule : Module<ApiResult> +/// { +/// protected override void DeclareDependencies(IDependencyDeclaration deps) +/// { +/// // Required dependency +/// deps.DependsOn<DatabaseModule>(); +/// +/// // Optional dependency (doesn't fail if not registered) +/// deps.DependsOnOptional<CachingModule>(); +/// +/// // Conditional dependency +/// if (Environment.IsProduction) +/// { +/// deps.DependsOn<PerformanceMonitoringModule>(); +/// } +/// +/// // Conditional with predicate +/// deps.DependsOnIf<HeavyProcessingModule>(() => ShouldRunHeavyProcessing); +/// } +/// } +/// +/// +public interface IDependencyDeclaration +{ + /// + /// Declares a required dependency on the specified module type. + /// + /// The type of module to depend on. + /// This instance for method chaining. + /// + /// Thrown during pipeline execution if the dependency is not registered. + /// + IDependencyDeclaration DependsOn() where TModule : IModule; + + /// + /// Declares a required dependency on the specified module type. + /// + /// The type of module to depend on. + /// This instance for method chaining. + /// + /// Thrown if the type does not implement . + /// + /// + /// Thrown during pipeline execution if the dependency is not registered. + /// + IDependencyDeclaration DependsOn(Type moduleType); + + /// + /// Declares an optional dependency on the specified module type. + /// Unlike , this will not fail if the dependency is not registered. + /// + /// The type of module to depend on. + /// This instance for method chaining. + IDependencyDeclaration DependsOnOptional() where TModule : IModule; + + /// + /// Declares an optional dependency on the specified module type. + /// Unlike , this will not fail if the dependency is not registered. + /// + /// The type of module to depend on. + /// This instance for method chaining. + /// + /// Thrown if the type does not implement . + /// + IDependencyDeclaration DependsOnOptional(Type moduleType); + + /// + /// Declares a conditional dependency that is only active if the condition is true. + /// + /// The type of module to depend on. + /// The condition that determines whether the dependency is active. + /// This instance for method chaining. + /// + /// If the condition is false, the dependency is not added at all. + /// + IDependencyDeclaration DependsOnIf(bool condition) where TModule : IModule; + + /// + /// Declares a conditional dependency that is only active if the predicate returns true. + /// + /// The type of module to depend on. + /// The predicate that determines whether the dependency is active. + /// This instance for method chaining. + /// + /// The predicate is evaluated immediately when this method is called. + /// If it returns false, the dependency is not added at all. + /// + IDependencyDeclaration DependsOnIf(Func predicate) where TModule : IModule; + + /// + /// Declares a conditional dependency that is only active if the condition is true. + /// + /// The type of module to depend on. + /// The condition that determines whether the dependency is active. + /// This instance for method chaining. + /// + /// Thrown if the type does not implement . + /// + IDependencyDeclaration DependsOnIf(Type moduleType, bool condition); + + /// + /// Declares a conditional dependency that is only active if the predicate returns true. + /// + /// The type of module to depend on. + /// The predicate that determines whether the dependency is active. + /// This instance for method chaining. + /// + /// Thrown if the type does not implement . + /// + IDependencyDeclaration DependsOnIf(Type moduleType, Func predicate); + + /// + /// Declares a lazy dependency - an optional dependency intended to be awaited on-demand. + /// + /// The type of module to depend on. + /// This instance for method chaining. + /// + /// + /// Lazy dependencies behave the same as for + /// dependency resolution purposes - the dependency is optional and will not fail if not + /// registered. This is a semantic marker to indicate intent that the dependency may be + /// awaited on-demand rather than required upfront. + /// + /// + /// Important: The lazy module will still execute during normal pipeline + /// scheduling if it is registered. It does NOT defer execution until explicitly awaited. + /// The "lazy" designation indicates the dependency relationship is optional and the result + /// may be consumed on-demand, but does not affect when the module runs. + /// + /// + /// Use this to express intent for optional processing that may or may not be awaited. + /// + /// + IDependencyDeclaration DependsOnLazy() where TModule : IModule; + + /// + /// Declares a lazy dependency - an optional dependency intended to be awaited on-demand. + /// + /// The type of module to depend on. + /// This instance for method chaining. + /// + /// + /// Lazy dependencies behave the same as for + /// dependency resolution purposes. See for full details. + /// + /// + /// + /// Thrown if the type does not implement . + /// + IDependencyDeclaration DependsOnLazy(Type moduleType); +} diff --git a/src/ModularPipelines/Modules/Module.cs b/src/ModularPipelines/Modules/Module.cs index 0d5e7dc71a..ee43f9b688 100644 --- a/src/ModularPipelines/Modules/Module.cs +++ b/src/ModularPipelines/Modules/Module.cs @@ -9,7 +9,9 @@ namespace ModularPipelines.Modules; /// /// The type of result returned by the module. /// +/// /// Modules can optionally implement behavior interfaces to customize execution: +/// /// /// - Define skip conditions /// - Set execution timeout @@ -18,7 +20,32 @@ namespace ModularPipelines.Modules; /// - Add before/after execution hooks /// - Run even when pipeline fails /// +/// +/// Dependencies can be declared in two ways: +/// +/// +/// Statically via attributes +/// Dynamically by overriding +/// /// +/// +/// +/// public class ApiModule : Module<ApiResult> +/// { +/// protected override void DeclareDependencies(IDependencyDeclaration deps) +/// { +/// deps.DependsOn<DatabaseModule>(); +/// deps.DependsOnOptional<CachingModule>(); +/// deps.DependsOnIf<MonitoringModule>(Environment.IsProduction); +/// } +/// +/// public override async Task<ApiResult?> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) +/// { +/// // Implementation +/// } +/// } +/// +/// public abstract class Module : IModule { internal TaskCompletionSource> CompletionSource { get; } = new(); @@ -26,6 +53,46 @@ public abstract class Module : IModule /// Type IModule.ResultType => typeof(T); + /// + /// Declares dependencies programmatically at runtime. + /// Override this method to add dynamic or conditional dependencies. + /// + /// The dependency declaration builder. + /// + /// + /// Dependencies declared here are combined with attribute-based dependencies + /// from . + /// + /// + /// This method is called once during module initialization, before execution begins. + /// + /// + /// + /// + /// protected override void DeclareDependencies(IDependencyDeclaration deps) + /// { + /// deps.DependsOn<RequiredModule>(); + /// deps.DependsOnOptional<OptionalModule>(); + /// deps.DependsOnIf<ConditionalModule>(SomeCondition); + /// deps.DependsOnLazy<HeavyModule>(); + /// } + /// + /// + protected virtual void DeclareDependencies(IDependencyDeclaration deps) + { + // Default implementation does nothing - dependencies are declared via attributes only + } + + /// + /// Internal method to collect declared dependencies. + /// + internal IReadOnlyList GetDeclaredDependencies() + { + var declaration = new DependencyDeclaration(); + DeclareDependencies(declaration); + return declaration.Dependencies; + } + /// /// Executes the module's core logic. /// @@ -34,5 +101,8 @@ public abstract class Module : IModule /// The result of the module execution, or null. public abstract Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken); + /// + /// Gets an awaiter for this module's result. + /// public TaskAwaiter> GetAwaiter() => CompletionSource.Task.GetAwaiter(); } diff --git a/test/ModularPipelines.UnitTests/DynamicDependencyDeclarationTests.cs b/test/ModularPipelines.UnitTests/DynamicDependencyDeclarationTests.cs new file mode 100644 index 0000000000..3da64ba78f --- /dev/null +++ b/test/ModularPipelines.UnitTests/DynamicDependencyDeclarationTests.cs @@ -0,0 +1,541 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.Enums; +using ModularPipelines.Exceptions; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using ModularPipelines.TestHelpers; +using Status = ModularPipelines.Enums.Status; + +namespace ModularPipelines.UnitTests; + +/// +/// Tests for the dynamic dependency declaration feature introduced in Issue #1870. +/// +public class DynamicDependencyDeclarationTests : TestBase +{ + #region Helper Modules + + /// + /// A basic module with no dependencies. + /// + private class BaseModule : SimpleTestModule + { + protected override string Result => "base"; + } + + /// + /// A module that others can optionally depend on. + /// + private class OptionalDependencyModule : SimpleTestModule + { + protected override string Result => "optional"; + } + + /// + /// A module for testing lazy dependencies. + /// + private class LazyModule : SimpleTestModule + { + protected override string Result => "lazy"; + } + + /// + /// A module for testing conditional dependencies. + /// + private class ConditionalModule : SimpleTestModule + { + protected override string Result => "conditional"; + } + + /// + /// A module that declares a required dependency programmatically. + /// + private class ModuleWithProgrammaticDependency : Module + { + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOn(); + } + + public override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + await Task.Yield(); + return "programmatic"; + } + } + + /// + /// A module that declares an optional dependency programmatically. + /// + private class ModuleWithOptionalDependency : Module + { + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOnOptional(); + } + + public override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + await Task.Yield(); + return "optional-dep"; + } + } + + /// + /// A module that declares a conditional dependency that is active. + /// + private class ModuleWithActiveConditionalDependency : Module + { + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOnIf(true); + } + + public override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + await Task.Yield(); + return "conditional-active"; + } + } + + /// + /// A module that declares a conditional dependency that is inactive. + /// + private class ModuleWithInactiveConditionalDependency : Module + { + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOnIf(false); + } + + public override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + await Task.Yield(); + return "conditional-inactive"; + } + } + + /// + /// A module that declares a conditional dependency using a predicate. + /// + private class ModuleWithPredicateConditionalDependency : Module + { + public static bool ShouldDependOnConditional { get; set; } = true; + + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOnIf(() => ShouldDependOnConditional); + } + + public override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + await Task.Yield(); + return "predicate-conditional"; + } + } + + /// + /// A module that declares a lazy dependency. + /// + private class ModuleWithLazyDependency : Module + { + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOnLazy(); + } + + public override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + await Task.Yield(); + return "lazy-dep"; + } + } + + /// + /// A module that combines both attribute and programmatic dependencies. + /// + [ModularPipelines.Attributes.DependsOn] + private class ModuleWithBothAttributeAndProgrammaticDependencies : Module + { + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOnOptional(); + } + + public override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + await Task.Yield(); + return "combined"; + } + } + + /// + /// A module that chains multiple dependency declarations. + /// + private class ModuleWithChainedDependencies : Module + { + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOn() + .DependsOnOptional() + .DependsOnLazy(); + } + + public override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + await Task.Yield(); + return "chained"; + } + } + + /// + /// A module that depends on an unregistered required dependency (should fail). + /// + private class ModuleWithMissingRequiredDependency : Module + { + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOn(); // BaseModule not registered + } + + public override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + await Task.Yield(); + return "missing-dep"; + } + } + + /// + /// A module that uses DependsOn with Type parameter. + /// + private class ModuleWithTypeDependency : Module + { + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOn(typeof(BaseModule)); + } + + public override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + await Task.Yield(); + return "type-dep"; + } + } + + #endregion + + #region Required Dependency Tests + + [Test] + public async Task Programmatic_Required_Dependency_Works_When_Registered() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + [Test] + public async Task Programmatic_Required_Dependency_Throws_When_Not_Registered() + { + await Assert.That(async () => await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync()) + .ThrowsException(); + } + + [Test] + public async Task Programmatic_Type_Dependency_Works() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + #endregion + + #region Optional Dependency Tests + + [Test] + public async Task Optional_Dependency_Works_When_Registered() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + [Test] + public async Task Optional_Dependency_Does_Not_Fail_When_Not_Registered() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + #endregion + + #region Conditional Dependency Tests + + [Test] + public async Task Conditional_Dependency_Works_When_Condition_True_And_Registered() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + [Test] + public async Task Conditional_Dependency_Throws_When_Condition_True_And_Not_Registered() + { + await Assert.That(async () => await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync()) + .ThrowsException(); + } + + [Test] + public async Task Conditional_Dependency_Not_Added_When_Condition_False() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + [Test] + [TUnit.Core.NotInParallel(Order = 1)] + public async Task Conditional_Predicate_Dependency_Works_When_Predicate_Returns_True() + { + ModuleWithPredicateConditionalDependency.ShouldDependOnConditional = true; + + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + [Test] + [TUnit.Core.NotInParallel(Order = 2)] + public async Task Conditional_Predicate_Dependency_Not_Added_When_Predicate_Returns_False() + { + ModuleWithPredicateConditionalDependency.ShouldDependOnConditional = false; + + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + #endregion + + #region Lazy Dependency Tests + + [Test] + public async Task Lazy_Dependency_Does_Not_Fail_When_Not_Registered() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + [Test] + public async Task Lazy_Dependency_Works_When_Registered() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + #endregion + + #region Combined Dependency Tests + + [Test] + public async Task Combined_Attribute_And_Programmatic_Dependencies_Work() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + [Test] + public async Task Combined_Dependencies_Work_With_Only_Attribute_Dependency_Registered() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + [Test] + public async Task Chained_Dependency_Declarations_Work() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + [Test] + public async Task Chained_Dependency_Declarations_Work_With_Only_Required_Registered() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + #endregion + + #region DependencyDeclaration Unit Tests + + [Test] + public async Task DependencyDeclaration_DependsOn_Returns_Same_Instance_For_Chaining() + { + var declaration = new DependencyDeclaration(); + + var result = declaration.DependsOn(); + + await Assert.That(result).IsSameReferenceAs(declaration); + } + + [Test] + public async Task DependencyDeclaration_DependsOnOptional_Returns_Same_Instance_For_Chaining() + { + var declaration = new DependencyDeclaration(); + + var result = declaration.DependsOnOptional(); + + await Assert.That(result).IsSameReferenceAs(declaration); + } + + [Test] + public async Task DependencyDeclaration_DependsOnIf_Returns_Same_Instance_For_Chaining() + { + var declaration = new DependencyDeclaration(); + + var result = declaration.DependsOnIf(true); + + await Assert.That(result).IsSameReferenceAs(declaration); + } + + [Test] + public async Task DependencyDeclaration_DependsOnLazy_Returns_Same_Instance_For_Chaining() + { + var declaration = new DependencyDeclaration(); + + var result = declaration.DependsOnLazy(); + + await Assert.That(result).IsSameReferenceAs(declaration); + } + + [Test] + public async Task DependencyDeclaration_Throws_For_Non_Module_Type() + { + var declaration = new DependencyDeclaration(); + + await Assert.That(() => declaration.DependsOn(typeof(string))) + .ThrowsException() + .And.HasMessageContaining("is not a Module"); + } + + [Test] + public async Task DependencyDeclaration_Required_Has_Correct_DependencyType() + { + var declaration = new DependencyDeclaration(); + declaration.DependsOn(); + + var deps = declaration.Dependencies; + + await Assert.That(deps).HasCount().EqualTo(1); + await Assert.That(deps[0].Kind).IsEqualTo(DependencyType.Required); + await Assert.That(deps[0].IgnoreIfNotRegistered).IsEqualTo(false); + } + + [Test] + public async Task DependencyDeclaration_Optional_Has_Correct_DependencyType() + { + var declaration = new DependencyDeclaration(); + declaration.DependsOnOptional(); + + var deps = declaration.Dependencies; + + await Assert.That(deps).HasCount().EqualTo(1); + await Assert.That(deps[0].Kind).IsEqualTo(DependencyType.Optional); + await Assert.That(deps[0].IgnoreIfNotRegistered).IsEqualTo(true); + } + + [Test] + public async Task DependencyDeclaration_Lazy_Has_Correct_DependencyType() + { + var declaration = new DependencyDeclaration(); + declaration.DependsOnLazy(); + + var deps = declaration.Dependencies; + + await Assert.That(deps).HasCount().EqualTo(1); + await Assert.That(deps[0].Kind).IsEqualTo(DependencyType.Lazy); + await Assert.That(deps[0].IgnoreIfNotRegistered).IsEqualTo(true); + } + + [Test] + public async Task DependencyDeclaration_Conditional_Has_Correct_DependencyType() + { + var declaration = new DependencyDeclaration(); + declaration.DependsOnIf(true); + + var deps = declaration.Dependencies; + + await Assert.That(deps).HasCount().EqualTo(1); + await Assert.That(deps[0].Kind).IsEqualTo(DependencyType.Conditional); + await Assert.That(deps[0].IgnoreIfNotRegistered).IsEqualTo(false); + } + + [Test] + public async Task DependencyDeclaration_Conditional_False_Does_Not_Add_Dependency() + { + var declaration = new DependencyDeclaration(); + declaration.DependsOnIf(false); + + var deps = declaration.Dependencies; + + await Assert.That(deps).HasCount().EqualTo(0); + } + + #endregion +}