Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

using ACAT.Core.Extensions;
using ACAT.Core.PanelManagement;
using ACAT.Core.DependencyInjection;
using ACAT.Core.Utility;
using ACAT.Core.Utility.TypeLoader;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
Expand Down Expand Up @@ -43,6 +45,16 @@ public void Cleanup()
Context.ServiceProvider = null;
}

// ---------------------------------------------------------------
// Minimal fake extension used only in ExtensionLoader tests
// ---------------------------------------------------------------

private interface IFakeExtension : IPluginExtension { }

// ---------------------------------------------------------------
// Existing ExtensionInstantiator tests (unchanged)
// ---------------------------------------------------------------

[TestMethod]
public void ExtensionInstantiator_WithServiceProvider_CanCreateExtensions()
{
Expand Down Expand Up @@ -144,5 +156,120 @@ public void ServiceProvider_CanCreateLoggers()
// Assert
Assert.IsNotNull(logger);
}

// ---------------------------------------------------------------
// ExtensionLoader tests
// ---------------------------------------------------------------

[TestMethod]
public void ExtensionLoader_Constructor_WithNullServiceProvider_ThrowsArgumentNullException()
{
// Act & Assert
Assert.ThrowsException<ArgumentNullException>(
() => new ExtensionLoader<IFakeExtension>(null));
}

[TestMethod]
public void ExtensionLoader_Constructor_WithServiceProvider_Succeeds()
{
// Act
var loader = new ExtensionLoader<IFakeExtension>(_serviceProvider);

// Assert
Assert.IsNotNull(loader);
}

[TestMethod]
public void ExtensionLoader_LoadedTypes_IsEmptyBeforeLoadingAssemblies()
{
// Arrange
var loader = new ExtensionLoader<IFakeExtension>(_serviceProvider);

// Assert
Assert.IsNotNull(loader.LoadedTypes);
Assert.AreEqual(0, loader.LoadedTypes.Count);
}

[TestMethod]
public void ExtensionLoader_CreateInstance_WithUnknownGuid_ReturnsNull()
{
// Arrange
var loader = new ExtensionLoader<IFakeExtension>(_serviceProvider);
var unknownId = Guid.NewGuid();

// Act
var instance = loader.CreateInstance(unknownId);

// Assert
Assert.IsNull(instance);
}

[TestMethod]
public void ExtensionLoader_CreateAllInstances_WithNoTypesLoaded_ReturnsEmptyCollection()
{
// Arrange
var loader = new ExtensionLoader<IFakeExtension>(_serviceProvider);

// Act
var instances = loader.CreateAllInstances();

// Assert
Assert.IsNotNull(instances);
Assert.IsFalse(instances.Any());
}

[TestMethod]
public void ExtensionLoader_RegisterExtensions_WithNullServices_ThrowsArgumentNullException()
{
// Arrange
var loader = new ExtensionLoader<IFakeExtension>(_serviceProvider);

// Act & Assert
Assert.ThrowsException<ArgumentNullException>(
() => loader.RegisterExtensions(null));
}

[TestMethod]
public void ExtensionLoader_RegisterExtensions_WithNoLoadedTypes_LeavesServicesUnchanged()
{
// Arrange
var loader = new ExtensionLoader<IFakeExtension>(_serviceProvider);
var services = new ServiceCollection();
var initialCount = services.Count;

// Act
loader.RegisterExtensions(services);

// Assert – no new registrations because no types were loaded
Assert.AreEqual(initialCount, services.Count);
}

[TestMethod]
public void ExtensionLoader_AddExtensionLoader_RegistersIExtensionLoaderAsSingleton()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
services.AddExtensionLoader<IFakeExtension>();
var provider = services.BuildServiceProvider();

// Act
var loader1 = provider.GetService<IExtensionLoader<IFakeExtension>>();
var loader2 = provider.GetService<IExtensionLoader<IFakeExtension>>();

// Assert – resolved as singleton (same reference both times)
Assert.IsNotNull(loader1);
Assert.AreSame(loader1, loader2);
}

[TestMethod]
public void ExtensionLoader_ImplementsIExtensionLoader()
{
// Act
var loader = new ExtensionLoader<IFakeExtension>(_serviceProvider);

// Assert
Assert.IsInstanceOfType(loader, typeof(IExtensionLoader<IFakeExtension>));
}
}
}
2 changes: 2 additions & 0 deletions src/Libraries/ACATCore/ACAT.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@
<Compile Include="UserManagement\ProfileManager.cs" />
<Compile Include="UserManagement\UserManager.cs" />
<Compile Include="Utility\AppInfo.cs" />
<Compile Include="Utility\TypeLoader\ExtensionLoader.cs" />
<Compile Include="Utility\TypeLoader\IExtensionLoader.cs" />
<Compile Include="Utility\TypeLoader\ITypeLoader.cs" />
<Compile Include="Utility\Disclaimers.cs" />
<Compile Include="Utility\Attributions.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
using ACAT.Core.WidgetManagement;
using ACAT.Core.TTSManagement;
using ACAT.Core.Utility;
using ACAT.Core.Utility.TypeLoader;
using ACAT.Core.WordPredictorManagement;
using Microsoft.Extensions.DependencyInjection;
using System;
Expand Down Expand Up @@ -301,6 +302,25 @@ public static IServiceCollection AddWidgetManagement(this IServiceCollection ser
return services;
}

/// <summary>
/// Registers <see cref="ExtensionLoader{TExtension}"/> as a singleton
/// <see cref="IExtensionLoader{TExtension}"/> for the specified extension type.
/// </summary>
/// <typeparam name="TExtension">
/// The plugin-extension interface type (must implement <see cref="IPluginExtension"/>).
/// </typeparam>
/// <param name="services">The service collection to configure.</param>
/// <returns>The service collection, for chaining.</returns>
public static IServiceCollection AddExtensionLoader<TExtension>(this IServiceCollection services)
where TExtension : class, IPluginExtension
{
if (services == null) throw new ArgumentNullException(nameof(services));

services.AddSingleton<IExtensionLoader<TExtension>>(
provider => new ExtensionLoader<TExtension>(provider));
return services;
}

/// <summary>
/// Registers ACAT configuration services including JSON schema validation,
/// hot-reload support, and environment-specific configuration.
Expand Down
167 changes: 167 additions & 0 deletions src/Libraries/ACATCore/Utility/TypeLoader/ExtensionLoader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
////////////////////////////////////////////////////////////////////////////
//
// Copyright 2013-2019; 2023 Intel Corporation
// SPDX-License-Identifier: Apache-2.0
//
//
// ExtensionLoader.cs
//
// Loads plugin extensions from assemblies and creates instances through the
// DI container. Wraps TypeLoader<TExtension> for type discovery and uses
// ActivatorUtilities so that extension constructors receive any registered
// services automatically.
//
////////////////////////////////////////////////////////////////////////////

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;

namespace ACAT.Core.Utility.TypeLoader
{
/// <summary>
/// Loads plugin extensions from assemblies and instantiates them via
/// dependency injection.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ExtensionLoader{TExtension}"/> ties together the two existing
/// primitives:
/// <list type="bullet">
/// <item><see cref="TypeLoader{TInterface}"/> – scans assemblies and builds a
/// GUID → <see cref="Type"/> cache.</item>
/// <item><see cref="ActivatorUtilities"/> – constructs objects whose constructors
/// may declare services registered in the DI container.</item>
/// </list>
/// </para>
/// <para>
/// Typical usage:
/// <code>
/// var loader = new ExtensionLoader&lt;IMyExtension&gt;(serviceProvider);
/// loader.LoadFromAssembly(path);
/// var instances = loader.CreateAllInstances();
/// </code>
/// </para>
/// <para>
/// To support a scenario where extensions themselves need to be resolved through
/// DI later (e.g., injected into other services), call
/// <see cref="RegisterExtensions"/> with the application's
/// <see cref="IServiceCollection"/> before building the container.
/// </para>
/// </remarks>
/// <typeparam name="TExtension">
/// The plugin-extension interface type. Must be a reference type that implements
/// <see cref="IPluginExtension"/>.
/// </typeparam>
public class ExtensionLoader<TExtension> : IExtensionLoader<TExtension>
where TExtension : class, IPluginExtension
{
private readonly IServiceProvider _serviceProvider;
private readonly TypeLoader<TExtension> _typeLoader;
private readonly ILogger<ExtensionLoader<TExtension>> _logger;

/// <summary>
/// Initialises a new <see cref="ExtensionLoader{TExtension}"/> that uses
/// <paramref name="serviceProvider"/> to resolve extension dependencies.
/// </summary>
/// <param name="serviceProvider">
/// The DI service provider used when creating extension instances.
/// </param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="serviceProvider"/> is <see langword="null"/>.
/// </exception>
public ExtensionLoader(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_typeLoader = new TypeLoader<TExtension>();
_logger = serviceProvider.GetService<ILogger<ExtensionLoader<TExtension>>>();
}

/// <inheritdoc/>
public IReadOnlyDictionary<Guid, Type> LoadedTypes => _typeLoader.LoadedTypes;

/// <inheritdoc/>
public void LoadFromAssembly(string assemblyPath, bool firstOrDefault = true)
{
_typeLoader.LoadFromAssembly(assemblyPath, firstOrDefault);
}

/// <inheritdoc/>
public void LoadFromAssemblies(IEnumerable<string> assemblyPaths)
{
_typeLoader.LoadFromAssemblies(assemblyPaths);
}

/// <inheritdoc/>
public TExtension CreateInstance(Guid id)
{
if (!_typeLoader.LoadedTypes.TryGetValue(id, out var type))
{
_logger?.LogWarning("No extension type found for ID {ExtensionId}", id);
return null;
}

try
{
_logger?.LogDebug("Creating extension instance for {TypeName}", type.FullName);
var instance = ActivatorUtilities.CreateInstance(_serviceProvider, type);

if (instance is TExtension extension)
{
_logger?.LogInformation("Successfully created extension: {ExtensionName}", type.Name);
return extension;
}

_logger?.LogWarning("Type {TypeName} does not implement {Interface}",
type.FullName, typeof(TExtension).FullName);
return null;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to create extension instance for {TypeName}", type.FullName);
return null;
}
}

/// <inheritdoc/>
public IEnumerable<TExtension> CreateAllInstances()
{
var instances = new List<TExtension>();

foreach (var kvp in _typeLoader.LoadedTypes)
{
var instance = CreateInstance(kvp.Key);
if (instance != null)
{
instances.Add(instance);
}
}

return instances;
}

/// <inheritdoc/>
public void RegisterExtensions(IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient)
{
if (services == null)
throw new ArgumentNullException(nameof(services));

foreach (var kvp in _typeLoader.LoadedTypes)
{
var extensionType = kvp.Value;

// Register the concrete type under itself
services.Add(new ServiceDescriptor(extensionType, extensionType, lifetime));

// Also register under the TExtension interface so callers can resolve
// IEnumerable<TExtension> or request TExtension directly
services.Add(new ServiceDescriptor(typeof(TExtension), extensionType, lifetime));

_logger?.LogDebug(
"Registered extension {TypeName} with lifetime {Lifetime}",
extensionType.FullName, lifetime);
}
}
}
}
Loading
Loading