diff --git a/SemanticKernelPooling.Tests.Mocks/MockAIConfiguration.cs b/SemanticKernelPooling.Tests.Mocks/MockAIConfiguration.cs new file mode 100644 index 0000000..9a82210 --- /dev/null +++ b/SemanticKernelPooling.Tests.Mocks/MockAIConfiguration.cs @@ -0,0 +1,36 @@ +namespace SemanticKernelPooling.Tests.Mocks; + +/// +/// Represents a mock configuration for testing kernel pool behavior. +/// This configuration simulates the settings required for AI service providers without actual credentials. +/// +/// +/// This mock configuration is used in unit tests to verify the kernel pooling behavior +/// without requiring actual AI service provider credentials or connections. +/// +public record MockAIConfiguration : AIServiceProviderConfiguration +{ + /// + /// Gets or initializes the API key used for testing. + /// In mock scenarios, this can be any non-null string. + /// + public required string ApiKey { get; init; } + + /// + /// Gets or initializes the model identifier used for testing. + /// In mock scenarios, this can be any non-null string. + /// + public required string ModelId { get; init; } + + /// + /// Gets or initializes the service identifier used for testing. + /// In mock scenarios, this can be any non-null string. + /// + public required string ServiceId { get; init; } + + /// + /// Gets or initializes the endpoint URL used for testing. + /// In mock scenarios, this can be any valid URL string. + /// + public required string Endpoint { get; init; } +} \ No newline at end of file diff --git a/SemanticKernelPooling.Tests.Mocks/MockKernelPool.cs b/SemanticKernelPooling.Tests.Mocks/MockKernelPool.cs new file mode 100644 index 0000000..86ef5fe --- /dev/null +++ b/SemanticKernelPooling.Tests.Mocks/MockKernelPool.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel; + +namespace SemanticKernelPooling.Tests.Mocks; + +/// +/// Provides a mock implementation of the kernel pool for testing purposes. +/// This implementation simulates the behavior of a real kernel pool without requiring actual AI service connections. +/// +/// +/// +/// The mock kernel pool tracks kernel creation and provides a controllable environment for testing +/// kernel pool behaviors including creation, disposal, and reuse patterns. +/// +/// +/// Example usage: +/// +/// var config = new MockAIConfiguration { ... }; +/// var pool = new MockKernelPool(config, loggerFactory, "path/to/responses"); +/// +/// +/// +public class MockKernelPool : AIServicePool +{ + private readonly ILoggerFactory _loggerFactory; + private int _kernelCreationCount; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration for the mock kernel pool. + /// The factory for creating loggers. + /// The path to mock response files. Defaults to "MockResponses". + public MockKernelPool( + MockAIConfiguration config, + ILoggerFactory loggerFactory) + : base(config) + { + _loggerFactory = loggerFactory; + } + + /// + /// Registers a mock chat completion service with the kernel builder. + /// + /// The kernel builder to configure. + /// The mock configuration to use. + /// Optional HTTP client to use. If null, a mock client will be created. + protected override void RegisterChatCompletionService( + IKernelBuilder kernelBuilder, + MockAIConfiguration config, + HttpClient? httpClient) + { + Interlocked.Increment(ref _kernelCreationCount); + } + + /// + protected override ILogger Logger => _loggerFactory.CreateLogger(); + + /// + /// Gets the number of kernels that have been created by this pool. + /// + public int KernelCreationCount => _kernelCreationCount; +} \ No newline at end of file diff --git a/SemanticKernelPooling.Tests.Mocks/SemanticKernelPooling.Tests.Mocks.csproj b/SemanticKernelPooling.Tests.Mocks/SemanticKernelPooling.Tests.Mocks.csproj new file mode 100644 index 0000000..a7dc87e --- /dev/null +++ b/SemanticKernelPooling.Tests.Mocks/SemanticKernelPooling.Tests.Mocks.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/SemanticKernelPooling.Tests.Mocks/ServiceExtension.cs b/SemanticKernelPooling.Tests.Mocks/ServiceExtension.cs new file mode 100644 index 0000000..1090fba --- /dev/null +++ b/SemanticKernelPooling.Tests.Mocks/ServiceExtension.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace SemanticKernelPooling.Tests.Mocks; + +/// +/// Provides extension methods for registering mock kernel pools with the service collection. +/// +/// +/// This extension follows the same pattern as the production service extensions but provides +/// mock implementations for testing purposes. +/// +public static class ServiceExtension +{ + /// + /// Registers mock kernel pool implementations for testing purposes. + /// + /// The DI service provider. + /// The service provider to enable method chaining. + /// + /// + /// This method registers mock implementations for both OpenAI and Azure OpenAI service types, + /// allowing tests to run without actual service dependencies. + /// + /// + /// Example usage: + /// + /// services.AddSingleton(mockConfiguration.Object); + /// services.UseSemanticKernelPooling(); + /// serviceProvider.UseMockKernelPool(); + /// + /// + /// + public static IServiceProvider UseMockKernelPool( + this IServiceProvider serviceProvider, + string responsesPath = "MockResponses") + { + var registrar = serviceProvider.GetRequiredService(); + + // Register factory for both mock providers + var factory = (AIServiceProviderConfiguration config, ILoggerFactory loggerFactory) => + new MockKernelPool((MockAIConfiguration)config, loggerFactory); + + registrar.RegisterKernelPoolFactory(AIServiceProviderType.OpenAI, factory); + registrar.RegisterKernelPoolFactory(AIServiceProviderType.AzureOpenAI, factory); + + // Register configuration readers + var configReader = (IConfigurationSection section) => + section.Get() ?? + throw new InvalidOperationException("Mock configuration not found."); + + registrar.RegisterConfigurationReader(AIServiceProviderType.OpenAI, configReader); + registrar.RegisterConfigurationReader(AIServiceProviderType.AzureOpenAI, configReader); + + return serviceProvider; + } +} \ No newline at end of file diff --git a/SemanticKernelPooling.Tests.Unit/KernelPoolManagerGetByNameTests.cs b/SemanticKernelPooling.Tests.Unit/KernelPoolManagerGetByNameTests.cs new file mode 100644 index 0000000..e5db807 --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/KernelPoolManagerGetByNameTests.cs @@ -0,0 +1,323 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Xunit.Abstractions; + +namespace SemanticKernelPooling.Tests.Unit; + +[Collection("KernelPoolManagerTests")] +public class KernelPoolManagerGetByNameTests +{ + private IServiceProvider _serviceProvider => _fixture.ServiceProvider; + private readonly SemanticKernelPoolTestFixture _fixture; + private ITestOutputHelper OutputHelper => _fixture.TestOutputHelper; + private const string ValidUniqueName = "MockOpenAI"; + private int PoolSize => _fixture.PoolSize; + + public KernelPoolManagerGetByNameTests(SemanticKernelPoolTestFixture fixture, ITestOutputHelper outputHelper) + { + _fixture = fixture; + _fixture.ConfigureLogging(outputHelper); + _fixture.InitializeServices(); + OutputHelper.WriteLine("Initializing KernelPoolManagerGetByNameTests"); + } + + [Fact] + public async Task GetKernelByName_WithValidName_ReturnsKernelWithCorrectServiceType() + { + OutputHelper.WriteLine($"Starting test: GetKernelByName with valid name '{ValidUniqueName}'"); + + var kernelPoolManager = _serviceProvider.GetRequiredService(); + + using var kernelWrapper = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + OutputHelper.WriteLine($"Retrieved kernel details:"); + OutputHelper.WriteLine($"- Service Type: {kernelWrapper.ServiceProviderType}"); + OutputHelper.WriteLine($"- Kernel Instance: {kernelWrapper.Kernel.GetHashCode()}"); + + Assert.NotNull(kernelWrapper); + Assert.NotNull(kernelWrapper.Kernel); + Assert.Equal(AIServiceProviderType.OpenAI, kernelWrapper.ServiceProviderType); + OutputHelper.WriteLine($"Successfully verified kernel properties"); + } + + [Fact] + public async Task GetKernelByName_WhenHoldingTwoKernels_BothAreDifferentInstances() + { + OutputHelper.WriteLine($"Starting test: GetKernelByName requesting two kernels simultaneously"); + + var kernelPoolManager = _serviceProvider.GetRequiredService(); + + using var wrapper1 = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + OutputHelper.WriteLine($"First kernel obtained:"); + OutputHelper.WriteLine($"- Instance Hash: {wrapper1.Kernel.GetHashCode()}"); + + using var wrapper2 = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + OutputHelper.WriteLine($"Second kernel obtained:"); + OutputHelper.WriteLine($"- Instance Hash: {wrapper2.Kernel.GetHashCode()}"); + + Assert.NotSame(wrapper1.Kernel, wrapper2.Kernel); + OutputHelper.WriteLine("Verified both kernels are different instances"); + } + + [Fact] + public async Task GetKernelByName_WhenDisposingAndReacquiring_ReusesKernel() + { + OutputHelper.WriteLine($"Starting test: GetKernelByName with disposal and reacquisition"); + + var kernelPoolManager = _serviceProvider.GetRequiredService(); + + Kernel firstKernel; + using (var wrapper1 = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName)) + { + firstKernel = wrapper1.Kernel; + OutputHelper.WriteLine($"First kernel obtained:"); + OutputHelper.WriteLine($"- Instance Hash: {firstKernel.GetHashCode()}"); + } + OutputHelper.WriteLine("First kernel disposed and returned to pool"); + + using var wrapper2 = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + OutputHelper.WriteLine($"Second kernel obtained:"); + OutputHelper.WriteLine($"- Instance Hash: {wrapper2.Kernel.GetHashCode()}"); + + Assert.Same(firstKernel, wrapper2.Kernel); + OutputHelper.WriteLine("Verified second kernel is same instance as first"); + } + + [Fact] + public async Task GetKernelByName_WithInvalidName_ThrowsInvalidOperationException() + { + OutputHelper.WriteLine("Starting test: GetKernelByName with invalid name"); + + // Arrange + var kernelPoolManager = _serviceProvider.GetRequiredService(); + const string invalidName = "NonExistentKernel"; + OutputHelper.WriteLine($"Attempting to get kernel with invalid name: {invalidName}"); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await kernelPoolManager.GetKernelByNameAsync(invalidName)); + + OutputHelper.WriteLine($"Exception details:"); + OutputHelper.WriteLine($"- Type: {exception.GetType().Name}"); + OutputHelper.WriteLine($"- Message: {exception.Message}"); + + // Verify the exception type and a more generic error message + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains($"No configuration found for kernel pool name {invalidName}", exception.Message); + + OutputHelper.WriteLine("Successfully verified exception type and message"); + } + + [Fact] + public async Task GetKernelByName_WhenPoolExhausted_WaitsForTimeoutThenThrows() + { + OutputHelper.WriteLine("Starting test: GetKernelByName with pool exhaustion and timeout"); + + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var kernels = new List(); + + try + { + // Exhaust all pools + for (int i = 0; i < PoolSize; i++) + { + var wrapper = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + kernels.Add(wrapper); + OutputHelper.WriteLine($"Obtained kernel {i + 1}: Hash {wrapper.Kernel.GetHashCode()}"); + } + + var startTime = DateTime.UtcNow; + OutputHelper.WriteLine($"All pools exhausted, attempting to get another kernel at {startTime:HH:mm:ss.fff}"); + + await Assert.ThrowsAsync( + async () => await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName)); + + var duration = DateTime.UtcNow - startTime; + OutputHelper.WriteLine($"Request failed after {duration.TotalSeconds:F2} seconds"); + } + finally + { + foreach (var kernel in kernels) + { + kernel.Dispose(); + } + } + } + + [Fact] + public async Task GetKernelByName_WhenKernelDisposedMultipleTimes_HandlesGracefully() + { + OutputHelper.WriteLine("Starting test: GetKernelByName with multiple dispose calls"); + + var kernelPoolManager = _serviceProvider.GetRequiredService(); + + var wrapper = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + var kernelHash = wrapper.Kernel.GetHashCode(); + OutputHelper.WriteLine($"Obtained kernel: Hash {kernelHash}"); + + // First dispose - should work normally + wrapper.Dispose(); + OutputHelper.WriteLine("First dispose completed"); + + // Second dispose - should not throw + wrapper.Dispose(); + OutputHelper.WriteLine("Second dispose completed without error"); + + // Verify we can get the same kernel back + using var newWrapper = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + OutputHelper.WriteLine($"Retrieved new kernel: Hash {newWrapper.Kernel.GetHashCode()}"); + + Assert.Equal(kernelHash, newWrapper.Kernel.GetHashCode()); + } + + [Fact] + public async Task GetKernelByName_UnderLoad_MaintainsPoolSize() + { + OutputHelper.WriteLine("Starting test: GetKernelByName under load conditions"); + + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var kernelHashes = new HashSet(); + const int numberOfIterations = 10; + + for (int i = 0; i < numberOfIterations; i++) + { + OutputHelper.WriteLine($"Iteration {i + 1} of {numberOfIterations}"); + var wrappers = new List(); + + // Request maximum pool size kernels + for (int j = 0; j < PoolSize; j++) + { + var wrapper = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + wrappers.Add(wrapper); + kernelHashes.Add(wrapper.Kernel.GetHashCode()); + OutputHelper.WriteLine($"Got kernel: Hash {wrapper.Kernel.GetHashCode()}"); + } + + // Dispose all kernels + foreach (var wrapper in wrappers) + { + wrapper.Dispose(); + } + OutputHelper.WriteLine("All kernels returned to pool"); + } + + // Verify we never created more unique kernels than the pool size + OutputHelper.WriteLine($"Total unique kernels created: {kernelHashes.Count}"); + Assert.Equal(PoolSize, kernelHashes.Count); + } + + [Fact(Skip = "Need to add possibility to cancel kernel request")] + public async Task GetKernelByName_WithCancellation_StopsWaiting() + { + OutputHelper.WriteLine("Starting test: GetKernelByName with cancellation"); + + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var cts = new CancellationTokenSource(); + + // Hold all available kernels + var heldKernels = new List(); + for (int i = 0; i < PoolSize; i++) + { + var wrapper = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + heldKernels.Add(wrapper); + OutputHelper.WriteLine($"Holding kernel {i + 1}: Hash {wrapper.Kernel.GetHashCode()}"); + } + + try + { + // Try to get another kernel with cancellation + var getKernelTask = Task.Run(async () => + { + try + { + return await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + } + catch (OperationCanceledException) + { + OutputHelper.WriteLine("Task was cancelled as expected"); + throw; + } + }, cts.Token); + + // Cancel after a short delay + await Task.Delay(100); + cts.Cancel(); + OutputHelper.WriteLine("Cancellation requested"); + + // Verify the operation was cancelled + await Assert.ThrowsAsync(() => getKernelTask); + } + finally + { + // Cleanup + foreach (var wrapper in heldKernels) + { + wrapper.Dispose(); + } + cts.Dispose(); + } + } + + [Fact] + public async Task GetKernelByName_WhenDisposedInDifferentThread_HandlesCorrectly() + { + OutputHelper.WriteLine("Starting test: GetKernelByName with disposal in different thread"); + + var kernelPoolManager = _serviceProvider.GetRequiredService(); + KernelWrapper wrapper = null!; + int kernelHash = 0; + + // Get kernel in main thread + wrapper = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + kernelHash = wrapper.Kernel.GetHashCode(); + OutputHelper.WriteLine($"Got kernel in main thread: Hash {kernelHash}"); + + // Dispose in different thread + await Task.Run(() => + { + OutputHelper.WriteLine($"Disposing kernel in separate thread: Hash {kernelHash}"); + wrapper.Dispose(); + }); + + // Verify we can get the same kernel back + using var newWrapper = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + OutputHelper.WriteLine($"Retrieved kernel back in main thread: Hash {newWrapper.Kernel.GetHashCode()}"); + Assert.Equal(kernelHash, newWrapper.Kernel.GetHashCode()); + } + + [Fact] + public async Task GetKernelByName_WithRapidRequestAndDispose_HandlesRaceConditions() + { + OutputHelper.WriteLine("Starting test: GetKernelByName with rapid request/dispose cycles"); + + var kernelPoolManager = _serviceProvider.GetRequiredService(); + const int numberOfCycles = 20; + var tasks = new List(); + var startTime = DateTime.UtcNow; + + for (int i = 0; i < numberOfCycles; i++) + { + var cycleIndex = i; + var task = Task.Run(async () => + { + OutputHelper.WriteLine($"Cycle {cycleIndex}: Requesting kernel at {(DateTime.UtcNow - startTime).TotalMilliseconds:F2}ms"); + + using var wrapper = await kernelPoolManager.GetKernelByNameAsync(ValidUniqueName); + var acquiredTime = DateTime.UtcNow; + OutputHelper.WriteLine($"Cycle {cycleIndex}: Acquired kernel Hash {wrapper.Kernel.GetHashCode()} at {(acquiredTime - startTime).TotalMilliseconds:F2}ms"); + + // Add small random delay to simulate some work + await Task.Delay(Random.Shared.Next(10, 50)); + + // Log before disposal + var disposingTime = DateTime.UtcNow; + OutputHelper.WriteLine($"Cycle {cycleIndex}: Disposing kernel Hash {wrapper.Kernel.GetHashCode()} at {(disposingTime - startTime).TotalMilliseconds:F2}ms"); + }); + tasks.Add(task); + } + + await Task.WhenAll(tasks); + var endTime = DateTime.UtcNow; + OutputHelper.WriteLine($"All cycles completed after {(endTime - startTime).TotalMilliseconds:F2}ms"); + } +} \ No newline at end of file diff --git a/SemanticKernelPooling.Tests.Unit/KernelPoolManagerGetByScopeTests.cs b/SemanticKernelPooling.Tests.Unit/KernelPoolManagerGetByScopeTests.cs new file mode 100644 index 0000000..51d5b81 --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/KernelPoolManagerGetByScopeTests.cs @@ -0,0 +1,250 @@ +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace SemanticKernelPooling.Tests.Unit; + +[Collection("KernelPoolManagerTests")] +public class KernelPoolManagerGetByScopeTests +{ + private IServiceProvider _serviceProvider => _fixture.ServiceProvider; + private readonly SemanticKernelPoolTestFixture _fixture; + private ITestOutputHelper OutputHelper => _fixture.TestOutputHelper; + private string TestScope1 => _fixture.TestScope1; + private int PoolSize => _fixture.PoolSize; + private int TotalPoolSize => PoolSize * 2; + + public KernelPoolManagerGetByScopeTests(SemanticKernelPoolTestFixture fixture, ITestOutputHelper outputHelper) + { + _fixture = fixture; + _fixture.ConfigureLogging(outputHelper); + _fixture.InitializeServices(); + OutputHelper.WriteLine("Initializing KernelPoolManagerGetByScopeTests"); + } + + [Fact] + public async Task GetKernelByScope_WithValidScope_ReturnsValidKernel() + { + OutputHelper.WriteLine($"Starting test: GetKernelByScope with scope '{TestScope1}'"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + + using var wrapper = await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + + OutputHelper.WriteLine($"Retrieved kernel details:"); + OutputHelper.WriteLine($"- Scope: {string.Join(", ", wrapper.Scopes)}"); + OutputHelper.WriteLine($"- Kernel Instance: {wrapper.Kernel.GetHashCode()}"); + + Assert.NotNull(wrapper); + Assert.NotNull(wrapper.Kernel); + Assert.Contains(TestScope1, wrapper.Scopes); + + OutputHelper.WriteLine("Successfully verified kernel properties"); + } + + [Fact] + public async Task GetKernelByScope_WithInvalidScope_ThrowsInvalidOperationException() + { + OutputHelper.WriteLine("Starting test: GetKernelByScope with invalid scope"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + const string invalidScope = "non-existent-scope"; + + var exception = await Assert.ThrowsAsync( + async () => await kernelPoolManager.GetKernelByScopeAsync(invalidScope)); + + OutputHelper.WriteLine($"Exception details: {exception.Message}"); + Assert.Contains($"No configuration found for scope {invalidScope}", exception.Message); + } + + [Fact] + public async Task GetKernelByScope_WhenRequestingTwo_AlternateBetweenPools() + { + // Arrange + OutputHelper.WriteLine("Starting test: GetKernelByScope alternates between pools"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + + // Act + using var wrapper1 = await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + OutputHelper.WriteLine($"First kernel obtained:"); + OutputHelper.WriteLine($"- Pool Unique Name: {wrapper1.UniqueName}"); + OutputHelper.WriteLine($"- Instance Hash: {wrapper1.Kernel.GetHashCode()}"); + + using var wrapper2 = await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + OutputHelper.WriteLine($"Second kernel obtained:"); + OutputHelper.WriteLine($"- Pool Unique Name: {wrapper2.UniqueName}"); + OutputHelper.WriteLine($"- Instance Hash: {wrapper2.Kernel.GetHashCode()}"); + + // Assert + Assert.NotNull(wrapper1.Kernel); + Assert.NotNull(wrapper2.Kernel); + Assert.NotEqual(wrapper1.Kernel.GetHashCode(), wrapper2.Kernel.GetHashCode()); + + Assert.Contains(TestScope1, wrapper1.Scopes); + Assert.Contains(TestScope1, wrapper2.Scopes); + + // Verify they're from different pools by checking their scopes + Assert.NotEqual(wrapper1.UniqueName, wrapper2.UniqueName); + + OutputHelper.WriteLine($"Verified kernels came from different pools - Pool 1: {wrapper1.UniqueName}, Pool 2: {wrapper2.UniqueName}"); + } + + [Fact] + public async Task GetKernelByScope_WhenAllPoolsExhausted_WaitsForTimeoutThenThrows() + { + OutputHelper.WriteLine("Starting test: GetKernelByScope with all pools exhausted"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var kernels = new List(); + + try + { + // Exhaust all pools + for (int i = 0; i < TotalPoolSize; i++) + { + var wrapper = await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + kernels.Add(wrapper); + OutputHelper.WriteLine($"Obtained kernel {i + 1}: Hash {wrapper.Kernel.GetHashCode()}"); + } + + var startTime = DateTime.UtcNow; + OutputHelper.WriteLine($"All pools exhausted, attempting to get another kernel at {startTime:HH:mm:ss.fff}"); + + await Assert.ThrowsAsync( + async () => await kernelPoolManager.GetKernelByScopeAsync(TestScope1)); + + var duration = DateTime.UtcNow - startTime; + OutputHelper.WriteLine($"Request failed after {duration.TotalSeconds:F2} seconds"); + } + finally + { + foreach (var kernel in kernels) + { + kernel.Dispose(); + } + } + } + + [Fact] + public async Task GetKernelByScope_WithMultipleRequests_DistributesLoad() + { + OutputHelper.WriteLine("Starting test: GetKernelByScope load distribution"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var poolUsage = new Dictionary(); + + for (int i = 0; i < 10; i++) + { + using var wrapper = await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + if (!poolUsage.ContainsKey(wrapper.UniqueName)) + { + poolUsage[wrapper.UniqueName] = 0; + } + poolUsage[wrapper.UniqueName]++; + + OutputHelper.WriteLine($"Request {i + 1} used pool: {wrapper.UniqueName}"); + } + + foreach (var usage in poolUsage) + { + OutputHelper.WriteLine($"Pool {usage.Key} was used {usage.Value} times"); + } + + Assert.True(poolUsage.Count > 1, "Load should be distributed across multiple pools"); + } + + [Fact] + public async Task GetKernelByScope_WithConcurrentRequests_HandlesThemCorrectly() + { + OutputHelper.WriteLine($"Starting test: GetKernelByScope with concurrent requests exceeding pool size"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var tasks = new List>(); + const int extraRequests = 3; // Number of requests exceeding pool size + var concurrentRequests = TotalPoolSize + extraRequests; + var successfulRequests = new List(); + var failedRequests = 0; + + OutputHelper.WriteLine($"Total pool size: {TotalPoolSize}"); + OutputHelper.WriteLine($"Extra requests: {extraRequests}"); + OutputHelper.WriteLine($"Total concurrent requests: {concurrentRequests}"); + + try + { + // Create multiple concurrent requests + for (int i = 0; i < concurrentRequests; i++) + { + var requestId = i; // Capture for logging + var task = Task.Run(async () => + { + try + { + OutputHelper.WriteLine($"Starting request {requestId + 1}/{concurrentRequests}"); + return await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + } + catch (InvalidOperationException ex) + { + OutputHelper.WriteLine($"Request {requestId + 1} failed as expected: {ex.Message}"); + Interlocked.Increment(ref failedRequests); + throw; + } + }); + tasks.Add(task); + } + + // Wait for all tasks and collect results + var results = await Task.WhenAll( + tasks.Select(async t => + { + try + { + return await t; + } + catch + { + return null; + } + }) + ); + + // Count successful requests + successfulRequests.AddRange(results.Where(r => r != null)!); + var uniqueKernels = successfulRequests.Select(w => w.Kernel.GetHashCode()).Distinct().Count(); + + OutputHelper.WriteLine($"\nRequest Summary:"); + OutputHelper.WriteLine($"- Total requests: {concurrentRequests}"); + OutputHelper.WriteLine($"- Successful requests: {successfulRequests.Count}"); + OutputHelper.WriteLine($"- Failed requests: {failedRequests}"); + OutputHelper.WriteLine($"- Unique kernels obtained: {uniqueKernels}"); + + // Verify results + Assert.Equal(TotalPoolSize, successfulRequests.Count); + Assert.Equal(extraRequests, failedRequests); + Assert.True(uniqueKernels <= TotalPoolSize, + $"Got {uniqueKernels} unique kernels, expected maximum of {TotalPoolSize}"); + } + finally + { + // Dispose successful kernels + foreach (var kernel in successfulRequests) + { + kernel.Dispose(); + } + OutputHelper.WriteLine("Cleaned up all successful kernel requests"); + } + } + + [Fact] + public async Task GetKernelByScope_WhenDisposedAndReacquired_ReusesKernels() + { + OutputHelper.WriteLine("Starting test: GetKernelByScope kernel reuse"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var seenKernels = new HashSet(); + const int configCount = 2; // Number of OpenAI configurations + + for (int i = 0; i < TotalPoolSize; i++) + { + using var wrapper = await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + var kernelHash = wrapper.Kernel.GetHashCode(); + OutputHelper.WriteLine($"Request {i + 1} got kernel with hash: {kernelHash} from {wrapper.UniqueName}"); + seenKernels.Add(kernelHash); + } + + OutputHelper.WriteLine($"Total unique kernels created: {seenKernels.Count}"); + Assert.Equal(TotalPoolSize / configCount, seenKernels.Count); + } +} \ No newline at end of file diff --git a/SemanticKernelPooling.Tests.Unit/KernelPoolManagerGetByServiceTypeTests.cs b/SemanticKernelPooling.Tests.Unit/KernelPoolManagerGetByServiceTypeTests.cs new file mode 100644 index 0000000..86e94d8 --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/KernelPoolManagerGetByServiceTypeTests.cs @@ -0,0 +1,251 @@ +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace SemanticKernelPooling.Tests.Unit; + +[Collection("KernelPoolManagerTests")] +public class KernelPoolManagerGetByServiceTypeTests +{ + private IServiceProvider _serviceProvider => _fixture.ServiceProvider; + private readonly SemanticKernelPoolTestFixture _fixture; + private ITestOutputHelper OutputHelper => _fixture.TestOutputHelper; + private int PoolSize => _fixture.PoolSize; + private int TotalPoolSize => PoolSize * 2; // Total across both configs + private const AIServiceProviderType TestProviderType = AIServiceProviderType.OpenAI; + + public KernelPoolManagerGetByServiceTypeTests(SemanticKernelPoolTestFixture fixture, ITestOutputHelper outputHelper) + { + _fixture = fixture; + _fixture.ConfigureLogging(outputHelper); + _fixture.InitializeServices(); + OutputHelper.WriteLine("Initializing KernelPoolManagerGetByServiceTypeTests"); + } + + [Fact] + public async Task GetKernelByType_WithValidType_ReturnsValidKernel() + { + // Arrange + OutputHelper.WriteLine($"Starting test: GetKernelByType with service type '{TestProviderType}'"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + + // Act + using var wrapper = await kernelPoolManager.GetKernelAsync(TestProviderType); + + // Log the details + OutputHelper.WriteLine($"Retrieved kernel details:"); + OutputHelper.WriteLine($"- Service Type: {wrapper.ServiceProviderType}"); + OutputHelper.WriteLine($"- Kernel Instance: {wrapper.Kernel.GetHashCode()}"); + + // Assert + Assert.NotNull(wrapper); + Assert.NotNull(wrapper.Kernel); + Assert.Equal(TestProviderType, wrapper.ServiceProviderType); + + OutputHelper.WriteLine("Successfully verified kernel properties"); + } + + [Fact(Skip = "need to fix GetByType round robin")] + public async Task GetKernelByType_WhenRequestingTwo_AlternatesBetweenPools() + { + // Arrange + OutputHelper.WriteLine("Starting test: GetKernelByType alternates between pools"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + + // Act + using var wrapper1 = await kernelPoolManager.GetKernelAsync(TestProviderType); + OutputHelper.WriteLine($"First kernel obtained:"); + OutputHelper.WriteLine($"- Pool Unique Name: {wrapper1.UniqueName}"); + OutputHelper.WriteLine($"- Instance Hash: {wrapper1.Kernel.GetHashCode()}"); + + using var wrapper2 = await kernelPoolManager.GetKernelAsync(TestProviderType); + OutputHelper.WriteLine($"Second kernel obtained:"); + OutputHelper.WriteLine($"- Pool Unique Name: {wrapper2.UniqueName}"); + OutputHelper.WriteLine($"- Instance Hash: {wrapper2.Kernel.GetHashCode()}"); + + // Assert + Assert.NotNull(wrapper1.Kernel); + Assert.NotNull(wrapper2.Kernel); + Assert.NotEqual(wrapper1.Kernel.GetHashCode(), wrapper2.Kernel.GetHashCode()); + + // Verify they're from different pools by checking their scopes + Assert.NotEqual(wrapper1.UniqueName, wrapper2.UniqueName); + + OutputHelper.WriteLine($"Verified kernels came from different pools - Pool 1: {wrapper1.UniqueName}, Pool 2: {wrapper2.UniqueName}"); + } + + [Fact(Skip = "need to fix GetByType round robin")] + public async Task GetKernelByType_WithInvalidType_ThrowsInvalidOperationException() + { + OutputHelper.WriteLine("Starting test: GetKernelByType with invalid service type"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + const AIServiceProviderType invalidType = (AIServiceProviderType)999; + + var exception = await Assert.ThrowsAsync( + async () => await kernelPoolManager.GetKernelAsync(invalidType)); + + OutputHelper.WriteLine($"Exception details: {exception.Message}"); + Assert.Contains("No configuration found for kernel pool type", exception.Message); + } + + [Fact(Skip = "need to fix GetByType round robin")] + public async Task GetKernelByType_WhenAllPoolsExhausted_WaitsForTimeoutThenThrows() + { + OutputHelper.WriteLine("Starting test: GetKernelByType with all pools exhausted"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var kernels = new List(); + + try + { + // Exhaust all pools + for (int i = 0; i < TotalPoolSize; i++) + { + var wrapper = await kernelPoolManager.GetKernelAsync(TestProviderType); + kernels.Add(wrapper); + OutputHelper.WriteLine($"Obtained kernel {i + 1}: Hash {wrapper.Kernel.GetHashCode()}"); + } + + var startTime = DateTime.UtcNow; + OutputHelper.WriteLine($"All pools exhausted, attempting to get another kernel at {startTime:HH:mm:ss.fff}"); + + await Assert.ThrowsAsync( + async () => await kernelPoolManager.GetKernelAsync(TestProviderType)); + + var duration = DateTime.UtcNow - startTime; + OutputHelper.WriteLine($"Request failed after {duration.TotalSeconds:F2} seconds"); + } + finally + { + foreach (var kernel in kernels) + { + kernel.Dispose(); + } + } + } + + [Fact(Skip = "need to fix GetByType round robin")] + public async Task GetKernelByType_WithMultipleRequests_DistributesLoad() + { + OutputHelper.WriteLine("Starting test: GetKernelByType load distribution"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var poolUsage = new Dictionary(); + + for (int i = 0; i < 10; i++) + { + using var wrapper = await kernelPoolManager.GetKernelAsync(TestProviderType); + if (!poolUsage.ContainsKey(wrapper.UniqueName)) + { + poolUsage[wrapper.UniqueName] = 0; + } + poolUsage[wrapper.UniqueName]++; + + OutputHelper.WriteLine($"Request {i + 1} used pool: {wrapper.UniqueName}"); + } + + foreach (var usage in poolUsage) + { + OutputHelper.WriteLine($"Pool {usage.Key} was used {usage.Value} times"); + } + + Assert.True(poolUsage.Count > 1, "Load should be distributed across multiple pools"); + } + + [Fact(Skip = "need to fix GetByType round robin")] + public async Task GetKernelByType_WithConcurrentRequests_HandlesThemCorrectly() + { + OutputHelper.WriteLine($"Starting test: GetKernelByType with concurrent requests exceeding pool size"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var tasks = new List>(); + const int extraRequests = 3; // Number of requests exceeding pool size + var concurrentRequests = TotalPoolSize + extraRequests; + var successfulRequests = new List(); + var failedRequests = 0; + + OutputHelper.WriteLine($"Total pool size: {TotalPoolSize}"); + OutputHelper.WriteLine($"Extra requests: {extraRequests}"); + OutputHelper.WriteLine($"Total concurrent requests: {concurrentRequests}"); + + try + { + // Create multiple concurrent requests + for (int i = 0; i < concurrentRequests; i++) + { + var requestId = i; // Capture for logging + var task = Task.Run(async () => + { + try + { + OutputHelper.WriteLine($"Starting request {requestId + 1}/{concurrentRequests}"); + return await kernelPoolManager.GetKernelAsync(TestProviderType); + } + catch (InvalidOperationException ex) + { + OutputHelper.WriteLine($"Request {requestId + 1} failed as expected: {ex.Message}"); + Interlocked.Increment(ref failedRequests); + throw; + } + }); + tasks.Add(task); + } + + // Wait for all tasks and collect results + var results = await Task.WhenAll( + tasks.Select(async t => + { + try + { + return await t; + } + catch + { + return null; + } + }) + ); + + // Count successful requests + successfulRequests.AddRange(results.Where(r => r != null)!); + var uniqueKernels = successfulRequests.Select(w => w.Kernel.GetHashCode()).Distinct().Count(); + + OutputHelper.WriteLine($"\nRequest Summary:"); + OutputHelper.WriteLine($"- Total requests: {concurrentRequests}"); + OutputHelper.WriteLine($"- Successful requests: {successfulRequests.Count}"); + OutputHelper.WriteLine($"- Failed requests: {failedRequests}"); + OutputHelper.WriteLine($"- Unique kernels obtained: {uniqueKernels}"); + + // Verify results + Assert.Equal(TotalPoolSize, successfulRequests.Count); + Assert.Equal(extraRequests, failedRequests); + Assert.True(uniqueKernels <= TotalPoolSize, + $"Got {uniqueKernels} unique kernels, expected maximum of {TotalPoolSize}"); + } + finally + { + // Dispose successful kernels + foreach (var kernel in successfulRequests) + { + kernel.Dispose(); + } + OutputHelper.WriteLine("Cleaned up all successful kernel requests"); + } + } + + [Fact(Skip = "need to fix GetByType round robin")] + public async Task GetKernelByType_WhenDisposedAndReacquired_ReusesKernels() + { + OutputHelper.WriteLine("Starting test: GetKernelByType kernel reuse"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var seenKernels = new HashSet(); + const int configCount = 2; // Number of OpenAI configurations + + for (int i = 0; i < TotalPoolSize; i++) + { + using var wrapper = await kernelPoolManager.GetKernelAsync(TestProviderType); + var kernelHash = wrapper.Kernel.GetHashCode(); + OutputHelper.WriteLine($"Request {i + 1} got kernel with hash: {kernelHash} from {wrapper.UniqueName}"); + seenKernels.Add(kernelHash); + } + + OutputHelper.WriteLine($"Total unique kernels created: {seenKernels.Count}"); + Assert.Equal(TotalPoolSize / configCount, seenKernels.Count); + } +} \ No newline at end of file diff --git a/SemanticKernelPooling.Tests.Unit/KernelPoolManagerInitializationTests.cs b/SemanticKernelPooling.Tests.Unit/KernelPoolManagerInitializationTests.cs new file mode 100644 index 0000000..9eb0fb7 --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/KernelPoolManagerInitializationTests.cs @@ -0,0 +1,162 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using SemanticKernelPooling.Tests.Mocks; +using Xunit.Abstractions; + +namespace SemanticKernelPooling.Tests.Unit; + +[Collection("KernelPoolManagerTests")] +public class KernelPoolManagerInitializationTests +{ + private IServiceProvider _serviceProvider => _fixture.ServiceProvider; + private readonly SemanticKernelPoolTestFixture _fixture; + private ITestOutputHelper OutputHelper => _fixture.TestOutputHelper; + private string TestScope1 => _fixture.TestScope1; + private string TestScope2 => _fixture.TestScope2; + + public KernelPoolManagerInitializationTests(SemanticKernelPoolTestFixture fixture, ITestOutputHelper outputHelper) + { + _fixture = fixture; + _fixture.ConfigureLogging(outputHelper); + _fixture.InitializeServices(); + OutputHelper.WriteLine("Initializing KernelPoolManagerInitializationTests"); + } + + [Fact] + public async Task RegisterForPreKernelCreation_WithScope_ExecutesForMatchingScope() + { + // Arrange + OutputHelper.WriteLine($"Starting test: RegisterForPreKernelCreation with scope '{TestScope1}'"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var executionCount = 0; + + // Register scoped pre-initialization action + kernelPoolManager.RegisterForPreKernelCreation(TestScope1, + (builder, config, options, scopes) => + { + OutputHelper.WriteLine("Executing preRegisteration"); + Assert.NotNull(scopes); // Verify scopes is not null + Assert.Contains(TestScope1, scopes); + Interlocked.Increment(ref executionCount); + OutputHelper.WriteLine($"Pre-initialization action executed for scopes: {string.Join(", ", scopes)}"); + }); + + // Act + using var wrapper1 = await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + using var wrapper2 = await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + + using var wrapperDifferentScope = await kernelPoolManager.GetKernelByScopeAsync(TestScope2); + + OutputHelper.WriteLine("wrapper 1 unique name: " + wrapper1.UniqueName); + OutputHelper.WriteLine("wrapper 2 unique name: " + wrapper2.UniqueName); + OutputHelper.WriteLine("wrapper Different Scope unique name: " + wrapperDifferentScope.UniqueName); + + // Assert + OutputHelper.WriteLine($"Execution count: {executionCount}"); + Assert.Equal(2, executionCount); + Assert.DoesNotContain(TestScope1, wrapperDifferentScope.Scopes); + } + + [Fact] + public async Task RegisterForAfterKernelCreation_WithScope_ExecutesForMatchingScope() + { + // Arrange + OutputHelper.WriteLine($"Starting test: RegisterForAfterKernelCreation with scope '{TestScope1}'"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var executionCount = 0; + Kernel? lastConfiguredKernel = null; + + // Register scoped post-initialization action + kernelPoolManager.RegisterForAfterKernelCreation(TestScope1, + (kernel, config, scopes) => + { + OutputHelper.WriteLine("Executing AfterRegisteration"); + Assert.NotNull(scopes); // Verify scopes is not null + Assert.Contains(TestScope1, scopes); + Interlocked.Increment(ref executionCount); + lastConfiguredKernel = kernel; + OutputHelper.WriteLine($"Post-initialization action executed for scopes: {string.Join(", ", scopes)}"); + }); + + // Act + using var wrapper1 = await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + using var wrapper2 = await kernelPoolManager.GetKernelByScopeAsync(TestScope1); + + using var wrapperDifferentScope= await kernelPoolManager.GetKernelByScopeAsync(TestScope2); + + OutputHelper.WriteLine("wrapper 1 unique name: " + wrapper1.UniqueName); + OutputHelper.WriteLine("wrapper 2 unique name: " + wrapper2.UniqueName); + OutputHelper.WriteLine("wrapper Different Scope unique name: " + wrapperDifferentScope.UniqueName); + + // Assert + OutputHelper.WriteLine($"Execution count: {executionCount}"); + Assert.Equal(2, executionCount); + Assert.Same(lastConfiguredKernel, wrapper2.Kernel); + Assert.DoesNotContain(TestScope1, wrapperDifferentScope.Scopes); + } + + [Fact(Skip ="fix register by configuration type")] + public async Task RegisterForPreKernelCreation_WithType_ExecutesForMatchingType() + { + // Arrange + OutputHelper.WriteLine("Starting test: RegisterForPreKernelCreation with type MockAIConfiguration"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var providerType = AIServiceProviderType.OpenAI; + var executionCount = 0; + + // Register type-specific pre-initialization action + kernelPoolManager.RegisterForPreKernelCreation( + (builder, config, options) => + { + OutputHelper.WriteLine("Executing preRegistration for MockAIConfiguration"); + Assert.IsType(config); + Interlocked.Increment(ref executionCount); + OutputHelper.WriteLine($"Pre-initialization action executed for config: {config.UniqueName}"); + }); + + // Act + using var wrapper1 = await kernelPoolManager.GetKernelAsync(providerType); + using var wrapper2 = await kernelPoolManager.GetKernelAsync(providerType); + + OutputHelper.WriteLine($"wrapper 1 type: {providerType}, unique name: {wrapper1.UniqueName}"); + OutputHelper.WriteLine($"wrapper 2 type: {providerType}, unique name: {wrapper2.UniqueName}"); + + // Assert + OutputHelper.WriteLine($"Execution count: {executionCount}"); + Assert.Equal(2, executionCount); + } + + [Fact(Skip = "fix register by configuration type")] + public async Task RegisterForAfterKernelCreation_WithType_ExecutesForMatchingType() + { + // Arrange + OutputHelper.WriteLine("Starting test: RegisterForAfterKernelCreation with type MockAIConfiguration"); + var kernelPoolManager = _serviceProvider.GetRequiredService(); + var providerType = AIServiceProviderType.OpenAI; + var executionCount = 0; + Kernel? lastConfiguredKernel = null; + + // Register type-specific post-initialization action + kernelPoolManager.RegisterForAfterKernelCreation( + (kernel, config) => + { + OutputHelper.WriteLine("Executing afterRegistration for MockAIConfiguration"); + Assert.IsType(config); + Interlocked.Increment(ref executionCount); + lastConfiguredKernel = kernel; + OutputHelper.WriteLine($"Post-initialization action executed for config: {config.UniqueName}"); + }); + + // Act + using var wrapper1 = await kernelPoolManager.GetKernelAsync(providerType); + using var wrapper2 = await kernelPoolManager.GetKernelAsync(providerType); + + OutputHelper.WriteLine($"wrapper 1 type: {providerType}, unique name: {wrapper1.UniqueName}"); + OutputHelper.WriteLine($"wrapper 2 type: {providerType}, unique name: {wrapper2.UniqueName}"); + + // Assert + OutputHelper.WriteLine($"Execution count: {executionCount}"); + Assert.Equal(2, executionCount); + Assert.Same(lastConfiguredKernel, wrapper2.Kernel); + } +} \ No newline at end of file diff --git a/SemanticKernelPooling.Tests.Unit/KernelPoolManagerTestCollection.cs b/SemanticKernelPooling.Tests.Unit/KernelPoolManagerTestCollection.cs new file mode 100644 index 0000000..f41ea42 --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/KernelPoolManagerTestCollection.cs @@ -0,0 +1,7 @@ +namespace SemanticKernelPooling.Tests.Unit; + +[CollectionDefinition("KernelPoolManagerTests")] +public class KernelPoolManagerTestCollection : ICollectionFixture +{ + // This class is just a marker for the collection, it remains empty +} diff --git a/SemanticKernelPooling.Tests.Unit/Logging/XUnitLogger.cs b/SemanticKernelPooling.Tests.Unit/Logging/XUnitLogger.cs new file mode 100644 index 0000000..db73e76 --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/Logging/XUnitLogger.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace SemanticKernelPooling.Tests.Unit.Logging +{ + internal class XUnitLogger : ILogger + { + private readonly ITestOutputHelper _testOutputHelper; + private readonly string _categoryName; + + public XUnitLogger(ITestOutputHelper testOutputHelper, string categoryName) + { + _testOutputHelper = testOutputHelper; + _categoryName = categoryName; + } + + public IDisposable BeginScope(TState state) where TState : notnull + => NoopDisposable.Instance; + + public bool IsEnabled(LogLevel logLevel) + => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + try + { + _testOutputHelper.WriteLine($"{_categoryName} [{eventId}] {formatter(state, exception!)}"); + + if (exception != null) + _testOutputHelper.WriteLine(exception.ToString()); + } + catch (InvalidOperationException) + { + //no active test, ignore! + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + } + + private class NoopDisposable : IDisposable + { + public static readonly NoopDisposable Instance = new NoopDisposable(); + public void Dispose() + { } + } + } +} diff --git a/SemanticKernelPooling.Tests.Unit/Logging/XUnitLoggerProvider.cs b/SemanticKernelPooling.Tests.Unit/Logging/XUnitLoggerProvider.cs new file mode 100644 index 0000000..4b003d4 --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/Logging/XUnitLoggerProvider.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace SemanticKernelPooling.Tests.Unit.Logging; + +public class XUnitLoggerProvider : ILoggerProvider +{ + private readonly ITestOutputHelper _testOutputHelper; + + public XUnitLoggerProvider(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + public ILogger CreateLogger(string categoryName) + => new XUnitLogger(_testOutputHelper, categoryName); + + public void Dispose(){ } +} \ No newline at end of file diff --git a/SemanticKernelPooling.Tests.Unit/SemanticKernelPoolTestFixture.cs b/SemanticKernelPooling.Tests.Unit/SemanticKernelPoolTestFixture.cs new file mode 100644 index 0000000..65eed73 --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/SemanticKernelPoolTestFixture.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SemanticKernelPooling.Tests.Mocks; +using SemanticKernelPooling.Tests.Unit.Logging; +using System.Net; +using System.Text.Json; +using Xunit.Abstractions; + +namespace SemanticKernelPooling.Tests.Unit; + +/// +/// Test fixture for Semantic Kernel Pool tests that provides configured services, +/// logging, and mock response handling capabilities. +/// +public class SemanticKernelPoolTestFixture : IDisposable +{ + /// + /// Gets the configured service provider containing all required services for testing. + /// + public IServiceProvider ServiceProvider { get; private set; } = null!; + + /// + /// Gets the test output helper for logging test information. + /// + public ITestOutputHelper TestOutputHelper + { + get + { + if (_testOutputHelper == null) + throw new InvalidOperationException("TestOutputHelper not configured. Call ConfigureLogging first."); + return _testOutputHelper; + } + private set => _testOutputHelper = value; + } + + private readonly string _mockResponsesPath; + private ILoggerProvider? _loggerProvider; + private ITestOutputHelper? _testOutputHelper; + + /// + /// The scope used for test configurations. + /// + public string TestScope1 = "test_scope_1"; + public string TestScope2 = "test_scope_2"; + + /// + /// The size of the kernel pool for testing. + /// + public int PoolSize = 2; + + /// + /// The mock deployment name used in test configurations. + /// + public const string DeploymentName = "mock-deployment"; + + /// + /// Initializes a new instance of the class. + /// + public SemanticKernelPoolTestFixture() + { + _mockResponsesPath = Path.Combine(Path.GetTempPath(), "MockResponses"); + Directory.CreateDirectory(_mockResponsesPath); + InitializeServices(); + } + + public void InitializeServices() + { + var services = new ServiceCollection(); + + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.SetMinimumLevel(LogLevel.Debug); + }); + + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + + services.AddSingleton(configuration); + services.UseSemanticKernelPooling(); + + ServiceProvider = services.BuildServiceProvider(); + ServiceProvider.UseMockKernelPool(_mockResponsesPath); + } + + /// + /// Configures logging for a specific test, ensuring test output is properly captured. + /// + /// The test output helper provided by XUnit. + public void ConfigureLogging(ITestOutputHelper testOutputHelper) + { + TestOutputHelper = testOutputHelper; // Set the property + var loggerFactory = ServiceProvider.GetRequiredService(); + + _loggerProvider?.Dispose(); + _loggerProvider = new XUnitLoggerProvider(testOutputHelper); + loggerFactory.AddProvider(_loggerProvider); + + TestOutputHelper.WriteLine("Logging configured successfully"); + } + + /// + /// Performs cleanup of managed and unmanaged resources. + /// + public void Dispose() + { + if (ServiceProvider is IDisposable disposable) + { + disposable.Dispose(); + } + _loggerProvider?.Dispose(); + + try + { + if (Directory.Exists(_mockResponsesPath)) + { + Directory.Delete(_mockResponsesPath, true); + } + } + catch (Exception ex) + { + // Since we're disposing, we might not have access to TestOutputHelper, + // but we'll try to log if we can + try + { + TestOutputHelper.WriteLine($"Error cleaning up mock responses directory: {ex.Message}"); + } + catch + { + // Nothing we can do here if TestOutputHelper is not available + } + } + } +} \ No newline at end of file diff --git a/SemanticKernelPooling.Tests.Unit/SemanticKernelPooling.Tests.Unit.csproj b/SemanticKernelPooling.Tests.Unit/SemanticKernelPooling.Tests.Unit.csproj new file mode 100644 index 0000000..cfa01fa --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/SemanticKernelPooling.Tests.Unit.csproj @@ -0,0 +1,43 @@ + + + + net9.0 + enable + enable + false + + + + + PreserveNewest + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/SemanticKernelPooling.Tests.Unit/TestOutputHelper.cs b/SemanticKernelPooling.Tests.Unit/TestOutputHelper.cs new file mode 100644 index 0000000..9002b80 --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/TestOutputHelper.cs @@ -0,0 +1,36 @@ +using Xunit.Abstractions; + +namespace SemanticKernelPooling.Tests.Unit; + +public class TestOutputHelper : ITestOutputHelper +{ + private readonly ITestOutputHelper _testOutputHelper; + + public TestOutputHelper(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + public void WriteLine(string message) + { + try + { + _testOutputHelper.WriteLine(message); + } + catch + { + Console.WriteLine(message); + } + } + + public void WriteLine(string format, params object[] args) + { + try + { + _testOutputHelper.WriteLine(format, args); + } + catch + { + Console.WriteLine(format, args); + } + } +} \ No newline at end of file diff --git a/SemanticKernelPooling.Tests.Unit/appsettings.json b/SemanticKernelPooling.Tests.Unit/appsettings.json new file mode 100644 index 0000000..2d76854 --- /dev/null +++ b/SemanticKernelPooling.Tests.Unit/appsettings.json @@ -0,0 +1,62 @@ +{ + "AIServiceProviderConfigurations": [ + { + "UniqueName": "MockOpenAI", + "ServiceType": "OpenAI", + "InstanceCount": 2, + "DeploymentTextEmbedding": "mock-embedding", + "MaxWaitForKernelInSeconds": 1, + "ApiKey": "mock-api-key", + "ModelId": "mock-model", + "ServiceId": "mock-service-id", + "Endpoint": "https://mock.endpoint", + "Scopes": [ + "test_scope_1" + ] + }, + { + "UniqueName": "MockOpenAI2", + "ServiceType": "OpenAI", + "InstanceCount": 2, + "DeploymentTextEmbedding": "mock-embedding", + "MaxWaitForKernelInSeconds": 1, + "ApiKey": "mock-api-key", + "ModelId": "mock-model", + "ServiceId": "mock-service-id", + "Endpoint": "https://mock.endpoint", + "Scopes": [ + "test_scope_2" + ] + }, + { + "UniqueName": "MockAzureOpenAI", + "ServiceType": "AzureOpenAI", + "InstanceCount": 2, + "DeploymentTextEmbedding": "mock-embedding", + "MaxWaitForKernelInSeconds": 1, + "ApiKey": "mock-api-key", + "ModelId": "mock-model", + "ServiceId": "mock-service-id", + "Endpoint": "https://mock.openai.azure.com", + "DeploymentName": "DeploymentName", + "Scopes": [ + "test_scope_1" + ] + }, + { + "UniqueName": "MockAzureOpenAI2", + "ServiceType": "AzureOpenAI", + "InstanceCount": 2, + "DeploymentTextEmbedding": "mock-embedding", + "MaxWaitForKernelInSeconds": 1, + "ApiKey": "mock-api-key", + "ModelId": "mock-model", + "ServiceId": "mock-service-id", + "Endpoint": "https://mock.openai.azure.com", + "DeploymentName": "DeploymentName", + "Scopes": [ + "test_scope_2" + ] + } + ] +} \ No newline at end of file diff --git a/SemanticKernelPooling.sln b/SemanticKernelPooling.sln index 64605d6..852d18b 100644 --- a/SemanticKernelPooling.sln +++ b/SemanticKernelPooling.sln @@ -26,6 +26,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "assets", "assets", "{6ACB62 assets\package-icon.png = assets\package-icon.png EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SemanticKernelPooling.Tests.Mocks", "SemanticKernelPooling.Tests.Mocks\SemanticKernelPooling.Tests.Mocks.csproj", "{D03855DF-C58C-40F2-A361-CDAF7FCEC88C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SemanticKernelPooling.Tests.Unit", "SemanticKernelPooling.Tests.Unit\SemanticKernelPooling.Tests.Unit.csproj", "{B8F6A4BF-2626-4367-95D4-FB00A8254785}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -56,6 +60,14 @@ Global {EF7CC525-73D8-4416-A52F-D8C44BEA539B}.Debug|Any CPU.Build.0 = Debug|Any CPU {EF7CC525-73D8-4416-A52F-D8C44BEA539B}.Release|Any CPU.ActiveCfg = Release|Any CPU {EF7CC525-73D8-4416-A52F-D8C44BEA539B}.Release|Any CPU.Build.0 = Release|Any CPU + {D03855DF-C58C-40F2-A361-CDAF7FCEC88C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D03855DF-C58C-40F2-A361-CDAF7FCEC88C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D03855DF-C58C-40F2-A361-CDAF7FCEC88C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D03855DF-C58C-40F2-A361-CDAF7FCEC88C}.Release|Any CPU.Build.0 = Release|Any CPU + {B8F6A4BF-2626-4367-95D4-FB00A8254785}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8F6A4BF-2626-4367-95D4-FB00A8254785}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8F6A4BF-2626-4367-95D4-FB00A8254785}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8F6A4BF-2626-4367-95D4-FB00A8254785}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SemanticKernelPooling/AIServicePool.cs b/SemanticKernelPooling/AIServicePool.cs index 3f8af73..2aff9bb 100644 --- a/SemanticKernelPooling/AIServicePool.cs +++ b/SemanticKernelPooling/AIServicePool.cs @@ -61,7 +61,13 @@ private Kernel CreateKernel() { var kernelBuilder = Kernel.CreateBuilder(); - TServiceProviderConfiguration newConfig = AIServiceProviderAIConfiguration with { UniqueName = $"{AIServiceProviderAIConfiguration.UniqueName}{CurrentNumberOfKernels}" }; + TServiceProviderConfiguration newConfig = AIServiceProviderAIConfiguration with { + UniqueName = $"{AIServiceProviderAIConfiguration.UniqueName}{CurrentNumberOfKernels}", + Scopes = AIServiceProviderAIConfiguration.Scopes, + InstanceCount = AIServiceProviderAIConfiguration.InstanceCount, + MaxWaitForKernelInSeconds = AIServiceProviderAIConfiguration.MaxWaitForKernelInSeconds, + DeploymentTextEmbedding = AIServiceProviderAIConfiguration.DeploymentTextEmbedding, + }; bool shouldAutoAddChatCompletionService = true; KernelBuilderOptions options = new(); diff --git a/SemanticKernelPooling/KernelPoolManager.cs b/SemanticKernelPooling/KernelPoolManager.cs index 5961f54..97df982 100644 --- a/SemanticKernelPooling/KernelPoolManager.cs +++ b/SemanticKernelPooling/KernelPoolManager.cs @@ -109,7 +109,7 @@ private async Task GetKernelAsync(AIServiceProviderConfiguration public async Task GetKernelByNameAsync(string uniqueName) { //get the service configuration - var config = AIConfigurations.First(c => c.UniqueName == uniqueName); + var config = AIConfigurations.FirstOrDefault(c => c.UniqueName == uniqueName); if (config == null) throw new InvalidOperationException($"No configuration found for kernel pool name {uniqueName}"); diff --git a/SemanticKernelPooling/KernelWrapper.cs b/SemanticKernelPooling/KernelWrapper.cs index 8a5f688..e32403d 100644 --- a/SemanticKernelPooling/KernelWrapper.cs +++ b/SemanticKernelPooling/KernelWrapper.cs @@ -51,4 +51,9 @@ public void Dispose() /// // ReSharper disable once UnusedMember.Global public AIServiceProviderType ServiceProviderType { get; } = pool.ServiceProviderType; + + /// + /// The Unique Name of the kernel pool of this wrapper. + /// + public string UniqueName { get; } = pool.UniqueName; } \ No newline at end of file