diff --git a/AIDevGallery.Tests/AIDevGallery.Tests.csproj b/AIDevGallery.Tests/AIDevGallery.Tests.csproj index 984a9bdb..c8fcde0e 100644 --- a/AIDevGallery.Tests/AIDevGallery.Tests.csproj +++ b/AIDevGallery.Tests/AIDevGallery.Tests.csproj @@ -12,7 +12,7 @@ true enable false - CS1591 + CS1591;IDISP001;IDISP002;IDISP003;IDISP004;IDISP006;IDISP007;IDISP008;IDISP017;IDISP025 Recommended false diff --git a/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalChatClientAdapterTests.cs b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalChatClientAdapterTests.cs new file mode 100644 index 00000000..7b116299 --- /dev/null +++ b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalChatClientAdapterTests.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.AI; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace AIDevGallery.Tests.UnitTests; + +/// +/// Tests for FoundryLocalChatClientAdapter focusing on pure functions and data transformations. +/// Note: Integration tests requiring actual FoundryLocal SDK initialization are excluded. +/// +[TestClass] +public class FoundryLocalChatClientAdapterTests +{ + [TestMethod] + public void ConvertToOpenAIMessagesConvertsMultipleMessagesWithDifferentRoles() + { + // Arrange + var messages = new List + { + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "What is the weather?"), + new(ChatRole.Assistant, "I don't have access to real-time weather data.") + }; + + // Act + var result = InvokeConvertToOpenAIMessages(messages); + + // Assert + Assert.AreEqual(3, result.Count); + Assert.AreEqual("system", result[0].Role); + Assert.AreEqual("You are a helpful assistant.", result[0].Content); + Assert.AreEqual("user", result[1].Role); + Assert.AreEqual("What is the weather?", result[1].Content); + Assert.AreEqual("assistant", result[2].Role); + Assert.AreEqual("I don't have access to real-time weather data.", result[2].Content); + } + + [TestMethod] + public void ConvertToOpenAIMessagesHandlesNullTextAsEmptyString() + { + // Arrange - Critical: null content should be converted to empty string, not cause NullReferenceException + var messages = new List + { + new(ChatRole.User, (string?)null) + }; + + // Act + var result = InvokeConvertToOpenAIMessages(messages); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual("user", result[0].Role); + Assert.AreEqual(string.Empty, result[0].Content); + } + + [TestMethod] + public void ConvertToOpenAIMessagesHandlesEmptyList() + { + // Arrange + var messages = new List(); + + // Act + var result = InvokeConvertToOpenAIMessages(messages); + + // Assert + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void ConvertToOpenAIMessagesHandlesCustomRoles() + { + // Arrange - Important: custom roles like "tool" should be preserved + var messages = new List + { + new(new ChatRole("tool"), "Tool output") + }; + + // Act + var result = InvokeConvertToOpenAIMessages(messages); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual("tool", result[0].Role); + Assert.AreEqual("Tool output", result[0].Content); + } + + [TestMethod] + public void ConvertToOpenAIMessagesPreservesMessageOrder() + { + // Arrange - Critical: message order must be preserved for proper conversation flow + var messages = new List + { + new(ChatRole.System, "System message"), + new(ChatRole.User, "First user message"), + new(ChatRole.Assistant, "First assistant response"), + new(ChatRole.User, "Second user message"), + new(ChatRole.Assistant, "Second assistant response") + }; + + // Act + var result = InvokeConvertToOpenAIMessages(messages); + + // Assert + Assert.AreEqual(5, result.Count); + Assert.AreEqual("system", result[0].Role); + Assert.AreEqual("System message", result[0].Content); + Assert.AreEqual("user", result[1].Role); + Assert.AreEqual("First user message", result[1].Content); + Assert.AreEqual("assistant", result[2].Role); + Assert.AreEqual("First assistant response", result[2].Content); + Assert.AreEqual("user", result[3].Role); + Assert.AreEqual("Second user message", result[3].Content); + Assert.AreEqual("assistant", result[4].Role); + Assert.AreEqual("Second assistant response", result[4].Content); + } + + [TestMethod] + public void ConvertToOpenAIMessagesOnlyTextContentIgnoresMultiModal() + { + // NOTE: This test documents current limitation - multi-modal content is not supported + // The current implementation only handles text content via ChatMessage.Text property + // Future enhancement may add image/audio support + + // Arrange - Message with only text content + var messages = new List + { + new(ChatRole.User, "Text only message") + }; + + // Act + var result = InvokeConvertToOpenAIMessages(messages); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual("Text only message", result[0].Content); + } + + [TestMethod] + public void ConvertToOpenAIMessagesMultipleConsecutiveSameRoleAllowed() + { + // Arrange - Some models allow multiple messages from same role + var messages = new List + { + new(ChatRole.User, "First question"), + new(ChatRole.User, "Second question"), + new(ChatRole.Assistant, "Combined answer") + }; + + // Act + var result = InvokeConvertToOpenAIMessages(messages); + + // Assert + Assert.AreEqual(3, result.Count); + Assert.AreEqual("user", result[0].Role); + Assert.AreEqual("user", result[1].Role); + Assert.AreEqual("assistant", result[2].Role); + } + + [TestMethod] + public void ConvertToOpenAIMessagesVeryLongMessageIsPreserved() + { + // Arrange - Test with a very long message + var longContent = new string('A', 10000); // 10K characters + var messages = new List + { + new(ChatRole.User, longContent) + }; + + // Act + var result = InvokeConvertToOpenAIMessages(messages); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual(longContent, result[0].Content); + Assert.AreEqual(10000, result[0].Content!.Length); + } + + [TestMethod] + public void ConvertToOpenAIMessagesSpecialCharactersArePreserved() + { + // Arrange - Test with special characters that might need escaping + var specialContent = "Hello\nWorld\tWith\"Quotes\" and 'apostrophes' & symbols <>"; + var messages = new List + { + new(ChatRole.User, specialContent) + }; + + // Act + var result = InvokeConvertToOpenAIMessages(messages); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual(specialContent, result[0].Content); + } + + /// + /// Uses reflection to invoke the private static ConvertToOpenAIMessages method. + /// + private static List InvokeConvertToOpenAIMessages( + IEnumerable messages) + { + var adapterType = Type.GetType("AIDevGallery.ExternalModelUtils.FoundryLocal.FoundryLocalChatClientAdapter, AIDevGallery"); + Assert.IsNotNull(adapterType, "FoundryLocalChatClientAdapter type not found"); + + var method = adapterType.GetMethod( + "ConvertToOpenAIMessages", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.IsNotNull(method, "ConvertToOpenAIMessages method not found"); + + var result = method.Invoke(null, new object[] { messages }); + return (List)result!; + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalDataModelsTests.cs b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalDataModelsTests.cs new file mode 100644 index 00000000..b855d10b --- /dev/null +++ b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalDataModelsTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AIDevGallery.ExternalModelUtils.FoundryLocal; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AIDevGallery.Tests.UnitTests; + +/// +/// Tests for FoundryLocal data models and utility types. +/// +public class FoundryLocalDataModelsTests +{ + [TestClass] + public class FoundryCatalogModelTests + { + [TestMethod] + public void FoundryCatalogModelAllPropertiesCanBeSetAndRetrieved() + { + // Arrange & Act + var model = new FoundryCatalogModel + { + Name = "phi-3.5-mini-instruct", + DisplayName = "Phi-3.5 Mini Instruct", + Alias = "phi-3.5-mini", + FileSizeMb = 2500, + License = "MIT", + ModelId = "microsoft/phi-3.5-mini", + Task = "chat-completion" + }; + + // Assert + Assert.AreEqual("phi-3.5-mini-instruct", model.Name); + Assert.AreEqual("Phi-3.5 Mini Instruct", model.DisplayName); + Assert.AreEqual("phi-3.5-mini", model.Alias); + Assert.AreEqual(2500, model.FileSizeMb); + Assert.AreEqual("MIT", model.License); + Assert.AreEqual("microsoft/phi-3.5-mini", model.ModelId); + Assert.AreEqual("chat-completion", model.Task); + } + + [TestMethod] + public void FoundryCatalogModelDefaultValuesAreZeroOrNull() + { + // Arrange & Act + var model = new FoundryCatalogModel(); + + // Assert + Assert.AreEqual(0, model.FileSizeMb); + Assert.IsTrue(string.IsNullOrEmpty(model.Name)); + Assert.IsTrue(string.IsNullOrEmpty(model.DisplayName)); + Assert.IsTrue(string.IsNullOrEmpty(model.Alias)); + } + } + + [TestClass] + public class FoundryCachedModelInfoTests + { + [TestMethod] + public void FoundryCachedModelInfoConstructorSetsProperties() + { + // Arrange & Act + var modelInfo = new FoundryCachedModelInfo("phi-3.5-mini-instruct", "phi-3.5-mini"); + + // Assert + Assert.AreEqual("phi-3.5-mini-instruct", modelInfo.Name); + Assert.AreEqual("phi-3.5-mini", modelInfo.Id); + } + + [TestMethod] + public void FoundryCachedModelInfoIsRecordSupportsValueEquality() + { + // Arrange + var info1 = new FoundryCachedModelInfo("test-model", "test-id"); + var info2 = new FoundryCachedModelInfo("test-model", "test-id"); + var info3 = new FoundryCachedModelInfo("different-model", "different-id"); + + // Assert - Record types support value-based equality + Assert.AreEqual(info1, info2, "Same values should be equal"); + Assert.AreNotEqual(info1, info3, "Different values should not be equal"); + } + } + + [TestClass] + public class FoundryDownloadResultTests + { + [TestMethod] + public void FoundryDownloadResultSuccessfulDownloadHasNoErrorMessage() + { + // Arrange & Act + var result = new FoundryDownloadResult(true, null); + + // Assert + Assert.IsTrue(result.Success); + Assert.IsNull(result.ErrorMessage); + } + + [TestMethod] + public void FoundryDownloadResultFailedDownloadHasErrorMessage() + { + // Arrange + var errorMsg = "Network timeout"; + + // Act + var result = new FoundryDownloadResult(false, errorMsg); + + // Assert + Assert.IsFalse(result.Success); + Assert.AreEqual(errorMsg, result.ErrorMessage); + } + + [TestMethod] + public void FoundryDownloadResultSuccessWithWarningBothSuccessAndMessage() + { + // Arrange - Important: download can succeed but have warnings + var warningMsg = "Model loaded but some features unavailable"; + + // Act + var result = new FoundryDownloadResult(true, warningMsg); + + // Assert + Assert.IsTrue(result.Success); + Assert.AreEqual(warningMsg, result.ErrorMessage); + } + } + + [TestClass] + public class ModelTaskTypesTests + { + [TestMethod] + public void ModelTaskTypesChatCompletionHasCorrectValue() + { + // Arrange & Act + var chatCompletion = ModelTaskTypes.ChatCompletion; + + // Assert + Assert.AreEqual("chat-completion", chatCompletion); + } + + [TestMethod] + public void ModelTaskTypesAutomaticSpeechRecognitionHasCorrectValue() + { + // Arrange & Act + var asr = ModelTaskTypes.AutomaticSpeechRecognition; + + // Assert + Assert.AreEqual("automatic-speech-recognition", asr); + } + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalModelProviderTests.cs b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalModelProviderTests.cs new file mode 100644 index 00000000..ce4ad1a4 --- /dev/null +++ b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalModelProviderTests.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AIDevGallery.ExternalModelUtils; +using AIDevGallery.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Threading.Tasks; + +namespace AIDevGallery.Tests.UnitTests; + +[TestClass] +public class FoundryLocalModelProviderTests +{ + // Basic properties tests - Consolidated + [TestMethod] + public void BasicPropertiesReturnExpectedValues() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + + // Assert - Test all basic properties together + Assert.AreEqual("FoundryLocal", provider.Name); + Assert.AreEqual(HardwareAccelerator.FOUNDRYLOCAL, provider.ModelHardwareAccelerator); + Assert.AreEqual("fl://", provider.UrlPrefix); + Assert.AreEqual(string.Empty, provider.Url, "Foundry Local uses direct SDK calls, not web service"); + Assert.AreEqual("The model will run locally via Foundry Local", provider.ProviderDescription); + Assert.AreEqual("Microsoft.AI.Foundry.Local", provider.IChatClientImplementationNamespace); + } + + [TestMethod] + public void NugetPackageReferencesContainsRequiredPackages() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + + // Act + var packages = provider.NugetPackageReferences; + + // Assert + Assert.IsNotNull(packages); + Assert.AreEqual(2, packages.Count, "Should contain exactly 2 packages"); + Assert.IsTrue(packages.Contains("Microsoft.AI.Foundry.Local.WinML")); + Assert.IsTrue(packages.Contains("Microsoft.Extensions.AI")); + } + + [TestMethod] + public void GetDetailsUrlReturnsNull() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + var modelDetails = new ModelDetails { Name = "test-model" }; + + // Act + var url = provider.GetDetailsUrl(modelDetails); + + // Assert + Assert.IsNull(url, "Foundry Local models run locally, so no online details page should be available"); + } + + [TestMethod] + public void InstanceIsSingleton() + { + // Arrange & Act + var instance1 = FoundryLocalModelProvider.Instance; + var instance2 = FoundryLocalModelProvider.Instance; + + // Assert + Assert.AreSame(instance1, instance2, "Instance should be a singleton"); + } + + // Edge case: Invalid model type + [TestMethod] + public async Task DownloadModelWithNonFoundryCatalogModelReturnsFalse() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + var modelDetails = new ModelDetails + { + Name = "test-model", + Url = "https://example.com/model", + ProviderModelDetails = "Not a FoundryCatalogModel" // Wrong type + }; + + // Act + var result = await provider.DownloadModel(modelDetails, null, default); + + // Assert + Assert.IsFalse(result.Success, "Should fail when ProviderModelDetails is wrong type"); + + // In unit test environment, manager might not be initialized, both error messages are valid + Assert.IsTrue( + result.ErrorMessage == "Invalid model details" || + result.ErrorMessage == "Foundry Local manager not initialized", + $"Expected error about invalid model or uninitialized manager, but got: {result.ErrorMessage}"); + } + + [TestMethod] + public async Task DownloadModelWithNullProviderModelDetailsReturnsFalse() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + var modelDetails = new ModelDetails + { + Name = "test-model", + Url = "https://example.com/model", + ProviderModelDetails = null // Null provider details + }; + + // Act + var result = await provider.DownloadModel(modelDetails, null, default); + + // Assert + Assert.IsFalse(result.Success, "Should fail when ProviderModelDetails is null"); + + // In unit test environment, manager might not be initialized, both error messages are valid + Assert.IsTrue( + result.ErrorMessage == "Invalid model details" || + result.ErrorMessage == "Foundry Local manager not initialized", + $"Expected error about invalid model or uninitialized manager, but got: {result.ErrorMessage}"); + } + + // Test initialization behavior + [TestMethod] + public async Task GetModelsAsyncWithIgnoreCachedTrueResetsDownloadedModels() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + + // Act - Call with ignoreCached=true + var models1 = await provider.GetModelsAsync(ignoreCached: true); + var models2 = await provider.GetModelsAsync(ignoreCached: false); + + // Assert - Both should return collections (empty or populated) + Assert.IsNotNull(models1); + Assert.IsNotNull(models2); + } + + [TestMethod] + public void GetAllModelsInCatalogReturnsEmptyCollectionBeforeInitialization() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + + // Act + var catalogModels = provider.GetAllModelsInCatalog(); + + // Assert - Should return empty collection if not initialized, not null + Assert.IsNotNull(catalogModels); + } + + [TestMethod] + public void ExtractAliasRemovesUrlPrefix() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + var testCases = new[] + { + ("fl://phi-3.5-mini-instruct", "phi-3.5-mini-instruct"), + ("fl://mistral-7b", "mistral-7b"), + ("fl://model-name", "model-name") + }; + + // Act & Assert + foreach (var (input, expected) in testCases) + { + // Use reflection to call private ExtractAlias method + var method = typeof(FoundryLocalModelProvider).GetMethod("ExtractAlias", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.IsNotNull(method, "ExtractAlias method should exist"); + + var result = method.Invoke(provider, new object[] { input }) as string; + Assert.AreEqual(expected, result, $"Failed to extract alias from {input}"); + } + } + + // Note: Tests for GetRequiredTasksForModelTypes are omitted because ModelType is generated by source generator + // and not directly accessible in unit tests. These methods are tested through integration tests. + [TestMethod] + public void GetIChatClientStringWithValidAliasReturnsCodeSnippet() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + var testUrl = "fl://test-model"; + + // Act + var result = provider.GetIChatClientString(testUrl); + + // Assert + // Before initialization, should return null + // The code snippet generation requires a loaded model + Assert.IsTrue( + result == null || result.Contains("FoundryLocalManager"), + "Should return null before initialization or contain FoundryLocalManager code"); + } + + [TestMethod] + public void IChatClientImplementationNamespaceReturnsCorrectNamespace() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + + // Act + var ns = provider.IChatClientImplementationNamespace; + + // Assert + Assert.AreEqual("Microsoft.AI.Foundry.Local", ns, "Should return the correct Foundry Local SDK namespace"); + } + + // Note: Icon property test is omitted because it depends on AppUtils.GetThemeAssetSuffix() + // which requires a running WinUI application context (not available in unit tests) + [TestMethod] + public async Task GetModelsAsyncCalledMultipleTimesDoesNotReinitialize() + { + // Arrange + var provider = FoundryLocalModelProvider.Instance; + + // Act + var models1 = await provider.GetModelsAsync(ignoreCached: false); + var models2 = await provider.GetModelsAsync(ignoreCached: false); + + // Assert + Assert.IsNotNull(models1); + Assert.IsNotNull(models2); + + // Both calls should succeed and return consistent results + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/UnitTests/Models/CachedModelTests.cs b/AIDevGallery.Tests/UnitTests/Models/CachedModelTests.cs new file mode 100644 index 00000000..68a47ddc --- /dev/null +++ b/AIDevGallery.Tests/UnitTests/Models/CachedModelTests.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AIDevGallery.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace AIDevGallery.Tests.UnitTests; + +[TestClass] +public class CachedModelTests +{ + [TestMethod] + public void CachedModelWithFoundryLocalUrlSetsSourceToFoundryLocal() + { + // Arrange + var modelDetails = new ModelDetails + { + Name = "Phi-3.5", + Url = "fl://phi-3.5-mini" + }; + + // Act + var cachedModel = new CachedModel(modelDetails, "/path/to/model", false, 1024); + + // Assert + Assert.AreEqual(CachedModelSource.FoundryLocal, cachedModel.Source); + Assert.AreEqual("fl://phi-3.5-mini", cachedModel.Url); + } + + [TestMethod] + public void CachedModelWithGitHubUrlSetsSourceToGitHub() + { + // Arrange + var modelDetails = new ModelDetails + { + Name = "Test Model", + Url = "https://github.com/user/repo/model.onnx" + }; + + // Act + var cachedModel = new CachedModel(modelDetails, "/path/to/model", true, 2048); + + // Assert + Assert.AreEqual(CachedModelSource.GitHub, cachedModel.Source); + } + + [TestMethod] + public void CachedModelWithLocalUrlSetsSourceToLocal() + { + // Arrange + var modelDetails = new ModelDetails + { + Name = "Local Model", + Url = "local://C:/models/model.onnx" + }; + + // Act + var cachedModel = new CachedModel(modelDetails, "C:/models/model.onnx", true, 512); + + // Assert + Assert.AreEqual(CachedModelSource.Local, cachedModel.Source); + } + + [TestMethod] + public void CachedModelWithHuggingFaceUrlSetsSourceToHuggingFace() + { + // Arrange + var modelDetails = new ModelDetails + { + Name = "HF Model", + Url = "microsoft/phi-2" + }; + + // Act + var cachedModel = new CachedModel(modelDetails, "/path/to/model", false, 4096); + + // Assert + Assert.AreEqual(CachedModelSource.HuggingFace, cachedModel.Source); + } + + [TestMethod] + public void CachedModelConstructorSetsAllProperties() + { + // Arrange + var modelDetails = new ModelDetails + { + Name = "Test Model", + Url = "fl://test-model" + }; + var path = "/test/path"; + var isFile = true; + var modelSize = 12345L; + + // Act + var cachedModel = new CachedModel(modelDetails, path, isFile, modelSize); + + // Assert + Assert.AreEqual(modelDetails, cachedModel.Details); + Assert.AreEqual(path, cachedModel.Path); + Assert.AreEqual(isFile, cachedModel.IsFile); + Assert.AreEqual(modelSize, cachedModel.ModelSize); + Assert.IsTrue(cachedModel.DateTimeCached <= DateTime.Now); + Assert.IsTrue(cachedModel.DateTimeCached > DateTime.Now.AddSeconds(-5)); + } + + [TestMethod] + public void CachedModelFoundryLocalUrlCaseInsensitive() + { + // Arrange - Test case insensitivity + var testUrls = new[] { "fl://model", "FL://model", "Fl://model" }; + + foreach (var url in testUrls) + { + var modelDetails = new ModelDetails + { + Name = "Test", + Url = url + }; + + // Act + var cachedModel = new CachedModel(modelDetails, "/path", false, 100); + + // Assert + Assert.AreEqual( + CachedModelSource.FoundryLocal, + cachedModel.Source, + $"URL '{url}' should be recognized as FoundryLocal"); + } + } + + [TestMethod] + public void CachedModelSourceFoundryLocalEnumExists() + { + // Verify the new enum value exists + var foundryLocalValue = CachedModelSource.FoundryLocal; + + Assert.IsTrue(Enum.IsDefined(foundryLocalValue)); + Assert.AreEqual("FoundryLocal", foundryLocalValue.ToString()); + } + + [TestMethod] + public void CachedModelSourceAllValuesAreDefined() + { + // Ensure all expected sources are defined + var expectedSources = new[] + { + CachedModelSource.GitHub, + CachedModelSource.HuggingFace, + CachedModelSource.Local, + CachedModelSource.FoundryLocal + }; + + foreach (var source in expectedSources) + { + Assert.IsTrue( + Enum.IsDefined(source), + $"CachedModelSource.{source} should be defined"); + } + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/UnitTests/Utils/ModelDownloadTests.cs b/AIDevGallery.Tests/UnitTests/Utils/ModelDownloadTests.cs new file mode 100644 index 00000000..778d45f3 --- /dev/null +++ b/AIDevGallery.Tests/UnitTests/Utils/ModelDownloadTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AIDevGallery.Models; +using AIDevGallery.Utils; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Reflection; + +namespace AIDevGallery.Tests.UnitTests; + +[TestClass] +public class ModelDownloadTests +{ + [TestMethod] + public void ModelDownloadEventArgsWithWarningMessageStoresWarning() + { + // Arrange + var warningMessage = "Model loaded but with minor issues"; + var eventArgs = new ModelDownloadEventArgs + { + Progress = 1.0f, + Status = DownloadStatus.Completed, + WarningMessage = warningMessage + }; + + // Act & Assert + Assert.AreEqual(warningMessage, eventArgs.WarningMessage); + Assert.AreEqual(1.0f, eventArgs.Progress); + Assert.AreEqual(DownloadStatus.Completed, eventArgs.Status); + } + + [TestMethod] + public void ModelDownloadEventArgsWithoutWarningMessageIsNull() + { + // Arrange + var eventArgs = new ModelDownloadEventArgs + { + Progress = 0.5f, + Status = DownloadStatus.InProgress + }; + + // Act & Assert + Assert.IsNull(eventArgs.WarningMessage); + } + + [TestMethod] + public void DownloadStatusHasExpectedValues() + { + // This test ensures the DownloadStatus enum has expected values + // Critical for FoundryLocal integration which uses these states + + // Assert + Assert.IsTrue(Enum.IsDefined(DownloadStatus.Waiting)); + Assert.IsTrue(Enum.IsDefined(DownloadStatus.InProgress)); + Assert.IsTrue(Enum.IsDefined(DownloadStatus.Completed)); + Assert.IsTrue(Enum.IsDefined(DownloadStatus.Canceled)); + } + + [TestMethod] + public void FoundryLocalModelDownloadIsSubclassOfModelDownload() + { + // Arrange + var foundryDownloadType = Type.GetType("AIDevGallery.Utils.FoundryLocalModelDownload, AIDevGallery"); + var modelDownloadType = typeof(ModelDownload); + + // Assert + Assert.IsNotNull(foundryDownloadType, "FoundryLocalModelDownload type should exist"); + Assert.IsTrue( + modelDownloadType.IsAssignableFrom(foundryDownloadType), + "FoundryLocalModelDownload should inherit from ModelDownload"); + } + + [TestMethod] + public void FoundryLocalModelDownloadConstructorInitializesWithModelDetails() + { + // Arrange + var modelDetails = new ModelDetails + { + Name = "test-model", + Url = "fl://test-model" + }; + + // Act + var download = CreateFoundryLocalModelDownload(modelDetails); + + // Assert + Assert.IsNotNull(download); + var details = GetProperty(download, "Details"); + Assert.AreEqual(modelDetails.Name, details.Name); + Assert.AreEqual(modelDetails.Url, details.Url); + } + + [TestMethod] + public void ModelDownloadWarningMessagePropertyExists() + { + // Verify that the WarningMessage property was added to ModelDownload base class + var modelDownloadType = typeof(ModelDownload); + var warningProperty = modelDownloadType.GetProperty("WarningMessage"); + + Assert.IsNotNull(warningProperty, "WarningMessage property should exist on ModelDownload"); + Assert.AreEqual(typeof(string), warningProperty.PropertyType); + } + + private static object CreateFoundryLocalModelDownload(ModelDetails modelDetails) + { + var type = Type.GetType("AIDevGallery.Utils.FoundryLocalModelDownload, AIDevGallery"); + Assert.IsNotNull(type, "FoundryLocalModelDownload type not found"); + + var constructor = type.GetConstructor(new[] { typeof(ModelDetails) }); + Assert.IsNotNull(constructor, "Constructor not found"); + + return constructor.Invoke(new object[] { modelDetails }); + } + + private static T GetProperty(object obj, string propertyName) + { + var property = obj.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + Assert.IsNotNull(property, $"Property {propertyName} not found"); + return (T)property.GetValue(obj)!; + } +} \ No newline at end of file diff --git a/AIDevGallery/AIDevGallery.csproj b/AIDevGallery/AIDevGallery.csproj index ccf7ada1..faeaca95 100644 --- a/AIDevGallery/AIDevGallery.csproj +++ b/AIDevGallery/AIDevGallery.csproj @@ -17,6 +17,8 @@ $(NoWarn);IL2050 $(NoWarn);IL2026 + + $(NoWarn);IDISP001;IDISP002;IDISP003;IDISP004;IDISP006;IDISP007;IDISP008;IDISP017;IDISP025 true SamplesRoots.xml @@ -80,6 +82,7 @@ + @@ -351,6 +354,9 @@ + + + Designer diff --git a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml index f2919cb1..75b5483e 100644 --- a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml +++ b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml @@ -97,17 +97,17 @@ AutomationProperties.Name="More info" Style="{StaticResource TertiaryButtonStyle}" ToolTipService.ToolTip="More info" - Visibility="{x:Bind utils:XamlHelpers.VisibleWhenNotNull(FoundryCatalogModel)}"> + Visibility="{x:Bind utils:XamlHelpers.VisibleWhenNotNull(FoundryCatalogModel.Runtime)}"> - + - + - + @@ -185,17 +185,6 @@ x:Name="DownloadableModelsTxt" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Available models on Foundry Local" /> - - - - - - - + - Install Foundry Local - + + Learn more about Foundry Local +