diff --git a/LocalLLMServerManager.Tests/BrowserLauncherTests.cs b/LocalLLMServerManager.Tests/BrowserLauncherTests.cs index b215cc1..d7d187e 100644 --- a/LocalLLMServerManager.Tests/BrowserLauncherTests.cs +++ b/LocalLLMServerManager.Tests/BrowserLauncherTests.cs @@ -93,4 +93,28 @@ public void OpenUrl_WithNonHttpOrHttpsScheme_ReturnsFalseAndShowsToast(string ur ToastService.Instance.OnToastShow -= handler; } } + + [Theory] + [InlineData("http://example.com/\"evil\"")] + [InlineData("http://example.com/'evil'")] + [InlineData("http://example.com/evil`cmd`")] + [InlineData("http://example.com/evil$var")] + [InlineData("http://example.com/evil\r\nsomething")] + public void OpenUrl_WithDangerousCharacters_ReturnsFalseAndShowsToast(string url) + { + ToastItem? emittedToast = null; + Action handler = t => emittedToast = t; + ToastService.Instance.OnToastShow += handler; + try + { + bool result = BrowserLauncher.OpenUrl(url); + Assert.False(result); + Assert.NotNull(emittedToast); + Assert.Contains("dangerous", emittedToast!.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + ToastService.Instance.OnToastShow -= handler; + } + } } diff --git a/LocalLLMServerManager.Tests/CanIRunItViewModelTests.cs b/LocalLLMServerManager.Tests/CanIRunItViewModelTests.cs index 88d8e79..b2f2f68 100644 --- a/LocalLLMServerManager.Tests/CanIRunItViewModelTests.cs +++ b/LocalLLMServerManager.Tests/CanIRunItViewModelTests.cs @@ -393,4 +393,178 @@ public async Task OllamaLibraryViewModel_ConfirmationModal_ConfirmDeletesModel() Assert.Null(vm.ModelToDelete); Assert.Empty(vm.InstalledModels); } + + [Theory] + [InlineData("Qwen 2.5 32B", 32.0)] + [InlineData("Qwen 2.5 72B", 72.0)] + [InlineData("DeepSeek R1 32B", 32.0)] + [InlineData("DeepSeek R1 70B", 70.0)] + [InlineData("Mistral Small 24B", 24.0)] + [InlineData("Gemma 2 9B", 9.0)] + [InlineData("Gemma 2 27B", 27.0)] + [InlineData("Phi-4 14B", 14.0)] + public void LlmPresets_Switching_SetsParametersCorrectly(string presetName, double expectedParams) + { + var vm = new CanIRunItViewModel(); + vm.SelectedPreset = presetName; + + Assert.Equal(expectedParams, vm.ParametersBillions); + Assert.Equal("Q4_K_M", vm.SelectedQuantization); + Assert.NotNull(vm.LlmResult); + } + + [Theory] + [InlineData("Flux.1 Schnell", 1024, "FP8")] + [InlineData("SDXL", 1024, "FP16")] + [InlineData("SD 3.5 Large", 1024, "FP8")] + [InlineData("SD 1.5", 512, "FP16")] + public void ImagePresets_Switching_SetsResolutionAndQuant(string preset, int expectedRes, string expectedQuant) + { + var vm = new CanIRunItViewModel(); + vm.SelectedModality = "Image"; + vm.SelectedImagePreset = preset; + + Assert.Equal(expectedRes, vm.SelectedImageResolution); + Assert.Equal(expectedQuant, vm.SelectedImageQuantization); + Assert.NotNull(vm.DiffusionResult); + } + + [Theory] + [InlineData("Wan 2.2 1.3B")] + [InlineData("LTX-Video")] + [InlineData("HunyuanVideo")] + public void VideoPresets_Switching_UpdatesCalculation(string preset) + { + var vm = new CanIRunItViewModel(); + vm.SelectedModality = "Video"; + vm.SelectedVideoPreset = preset; + vm.SelectedVideoResolution = "480p"; + vm.SelectedVideoQuantization = "Q4"; + + Assert.NotNull(vm.VideoResult); + Assert.Equal(preset, vm.VideoResult.ModelName); + } + + [Theory] + [InlineData("Faster-Whisper Large-v3-Turbo")] + [InlineData("AllTalk XTTS-v2")] + [InlineData("MusicGen Melody")] + public void AudioPresets_Switching_UpdatesCalculation(string preset) + { + var vm = new CanIRunItViewModel(); + vm.SelectedModality = "Audio"; + vm.SelectedAudioPreset = preset; + + Assert.NotNull(vm.AudioResult); + Assert.Equal(preset, vm.AudioResult.EngineName); + } + + [Fact] + public void ThreeDPresets_Switching_UpdatesCalculation() + { + var vm = new CanIRunItViewModel(); + vm.SelectedModality = "ThreeD"; + vm.SelectedThreeDPreset = "Hunyuan3D-2"; + + Assert.NotNull(vm.ThreeDResult); + Assert.Equal("Hunyuan3D-2", vm.ThreeDResult.ModelName); + } + + [Theory] + [InlineData("DIFFUSION", "Image")] + [InlineData("IMAGE", "Image")] + [InlineData("VIDEO", "Video")] + [InlineData("SPEECH", "Audio")] + [InlineData("TTS", "Audio")] + [InlineData("AUDIO", "Audio")] + [InlineData("3D", "ThreeD")] + [InlineData("THREED", "ThreeD")] + [InlineData("UNKNOWN_MODALITY", "LLM")] + public void SelectModality_StringCommand_MapsCorrectly(string input, string expectedModality) + { + var vm = new CanIRunItViewModel(); + vm.SelectModality(input); + + Assert.Equal(expectedModality, vm.SelectedModality); + } + + [Fact] + public void SelectModality_EmptyInput_DoesNothing() + { + var vm = new CanIRunItViewModel(); + vm.SelectedModality = "Video"; + vm.SelectModality(""); + Assert.Equal("Video", vm.SelectedModality); + } + + [Fact] + public void ModalityCommands_RelayExecution_SwitchesState() + { + var vm = new CanIRunItViewModel(); + vm.Select3DModality(); + Assert.Equal("ThreeD", vm.SelectedModality); + + vm.SelectLlmModality(); + Assert.Equal("LLM", vm.SelectedModality); + } + + [Theory] + [InlineData("my-custom-model-8b.Q8_0.gguf", 8.0, "Q8_0")] + [InlineData("qwen2.5-14b-instruct.fp16.bin", 14.0, "FP16")] + [InlineData("custom-model-27b-chat.gguf", 27.0, "Q4_K_M")] + [InlineData("tiny-model-1.5b.fp8.safetensors", 1.5, "FP8")] + [InlineData("unknown-model-without-params.gguf", 8.0, "Q4_K_M")] + public void InspectModel_CustomLlmNames_ExtractsParamsAndQuant(string name, double expectedB, string expectedQuant) + { + var vm = new CanIRunItViewModel(); + vm.InspectModel(name, "LLM"); + + Assert.Equal("Custom", vm.SelectedPreset); + Assert.Equal(expectedB, vm.ParametersBillions); + Assert.Equal(expectedQuant, vm.SelectedQuantization); + } + + [Fact] + public async Task RefreshTelemetryAsync_QueriesServiceAndUpdateHardware() + { + var mockTelemetry = new Mock(); + mockTelemetry.Setup(t => t.QueryGpuVramAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new GpuTelemetryInfo("NVIDIA RTX 4080", 2.0, 16.0, 14.0, 10)); + + var vm = new CanIRunItViewModel(mockTelemetry.Object); + using var client = new HttpClient(); + await vm.RefreshTelemetryAsync("http://localhost:5246", client); + + Assert.Equal("NVIDIA RTX 4080", vm.GpuName); + Assert.Equal(16384.0, vm.TotalVramMb); + } + + [Fact] + public void LowMemory_Verdicts_CoverFormattingBranches() + { + var vm = new CanIRunItViewModel(); + // Set VRAM and RAM very low to trigger OOM/CPU verdicts across modalities + vm.UpdateHardwareTelemetry(512.0, 512.0, 1024.0, 1024.0, "Low-End iGPU"); + + vm.SelectModality("LLM"); + vm.SelectedPreset = "DeepSeek R1 671B"; + Assert.Equal(FitVerdict.OutOfMemory, vm.LlmResult?.FitVerdict); + Assert.Contains("Out of Memory", vm.VerdictSummaryText); + + vm.SelectModality("Image"); + vm.SelectedImagePreset = "Flux.1 Dev"; + Assert.Equal(FitVerdict.OutOfMemory, vm.DiffusionResult?.FitVerdict); + Assert.Contains("Out of Memory", vm.VerdictSummaryText); + + vm.SelectModality("Video"); + vm.SelectedVideoPreset = "Wan 2.2 14B"; + Assert.Equal(FitVerdict.OutOfMemory, vm.VideoResult?.FitVerdict); + Assert.Contains("Out of Memory", vm.VerdictSummaryText); + + vm.SelectModality("Audio"); + Assert.NotNull(vm.AudioResult); + + vm.SelectModality("ThreeD"); + Assert.NotNull(vm.ThreeDResult); + } } diff --git a/LocalLLMServerManager.Tests/CivitaiSearchViewModelTests.cs b/LocalLLMServerManager.Tests/CivitaiSearchViewModelTests.cs new file mode 100644 index 0000000..c983a4a --- /dev/null +++ b/LocalLLMServerManager.Tests/CivitaiSearchViewModelTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using LocalLLMServerManager.Shared.Interfaces; +using LocalLLMServerManager.Shared.Models; +using LocalLLMServerManager.Shared.Services; +using LocalLLMServerManager.Shared.ViewModels; +using Moq; +using Moq.Protected; +using Xunit; + +namespace LocalLLMServerManager.Tests; + +public class CivitaiSearchViewModelTests +{ + [Fact] + public void InitialState_DefaultValues_ConfiguredCorrectly() + { + var mockCivitai = new Mock(); + var vm = new CivitaiSearchViewModel(mockCivitai.Object); + + Assert.Equal("", vm.CivitaiSearchQuery); + Assert.Equal("Checkpoint", vm.SelectedCivitaiType); + Assert.True(vm.IsFullVramActive); + Assert.True(vm.IsPartialOffloadActive); + Assert.True(vm.IsCpuOnlyActive); + Assert.True(vm.IsOomActive); + Assert.Equal(16384.0, vm.TotalVramMb); + Assert.Equal(32768.0, vm.TotalRamMb); + } + + [Fact] + public void ToggleFitVerdict_TogglesFilterFlagsAndAppliesFilter() + { + var mockCivitai = new Mock(); + var vm = new CivitaiSearchViewModel(mockCivitai.Object); + + vm.ToggleFitVerdict("full"); + Assert.False(vm.IsFullVramActive); + + vm.ToggleFitVerdict("partial"); + Assert.False(vm.IsPartialOffloadActive); + + vm.ToggleFitVerdict("cpu"); + Assert.False(vm.IsCpuOnlyActive); + + vm.ToggleFitVerdict("oom"); + Assert.False(vm.IsOomActive); + } + + [Fact] + public void NavigateToCanIRunIt_And_InspectModel_InvokeCallbacks() + { + var mockCivitai = new Mock(); + var vm = new CivitaiSearchViewModel(mockCivitai.Object); + + string? inspectedModel = null; + string? inspectedModality = null; + vm.OnInspectModelRequested = (m, mod) => + { + inspectedModel = m; + inspectedModality = mod; + }; + + vm.NavigateToCanIRunIt("Flux-Dev-Checkpoint"); + Assert.Equal("Flux-Dev-Checkpoint", inspectedModel); + Assert.Equal("Image", inspectedModality); + + var item = new CivitaiModelItem(42, "SDXL Turbo", "Checkpoint", "http://img.jpg", "http://dl.safetensors", "sdxl.safetensors", 4.8, 1200); + vm.InspectModel(item); + Assert.Equal("SDXL Turbo", inspectedModel); + } + + [Fact] + public void UpdateHardwareTelemetry_UpdatesVramAndRecalculatesBadges() + { + var mockCivitai = new Mock(); + var vm = new CivitaiSearchViewModel(mockCivitai.Object); + + vm.CivitaiResults.Add(new CivitaiModelItem(1, "Flux Dev", "Checkpoint", "http://img", "http://dl", "flux.safetensors", 5.0, 500)); + vm.UpdateHardwareTelemetry(8192.0, 16384.0); + + Assert.Equal(8192.0, vm.TotalVramMb); + Assert.Equal(16384.0, vm.TotalRamMb); + Assert.NotNull(vm.CivitaiResults[0].FitBadge); + } + + [Fact] + public async Task SearchCivitaiAsync_PopulatesResultsAndAppliesFilter() + { + var mockCivitai = new Mock(); + mockCivitai.Setup(s => s.SearchModelsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new CivitaiModelItem(10, "SD 1.5 Photoreal", "Checkpoint", "http://img.png", "http://dl.gguf", "sd15.safetensors", 4.5, 300) + }); + + var vm = new CivitaiSearchViewModel(mockCivitai.Object); + using var client = new HttpClient(); + await vm.SearchCivitaiAsync("http://localhost:5246", client); + + Assert.Single(vm.CivitaiResults); + Assert.Single(vm.FilteredCivitaiResults); + Assert.Equal("SD 1.5 Photoreal", vm.CivitaiResults[0].Name); + } + + [Fact] + public async Task DownloadCivitaiModelAsync_SendsDownloadRequest() + { + var mockCivitai = new Mock(); + var vm = new CivitaiSearchViewModel(mockCivitai.Object); + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.RequestUri != null && r.RequestUri.ToString().Contains("/api/civitai/download")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + + var client = new HttpClient(handlerMock.Object); + var item = new CivitaiModelItem(10, "SD 1.5 Photoreal", "Checkpoint", "http://img.png", "http://dl.gguf", "sd15.safetensors", 4.5, 300); + + await vm.DownloadCivitaiModelAsync(item, "http://localhost:5246", client); + } +} diff --git a/LocalLLMServerManager.Tests/ComponentManagerAndEndpointsTests.cs b/LocalLLMServerManager.Tests/ComponentManagerAndEndpointsTests.cs index 2b0dc84..9493e2f 100644 --- a/LocalLLMServerManager.Tests/ComponentManagerAndEndpointsTests.cs +++ b/LocalLLMServerManager.Tests/ComponentManagerAndEndpointsTests.cs @@ -2,6 +2,8 @@ using System.Net.Http.Json; using LocalLLMServerManager.Services; using LocalLLMServerManager.Shared.Models; +using Moq; +using Moq.Protected; using Xunit; namespace LocalLLMServerManager.Tests; @@ -72,13 +74,30 @@ public async Task UninstallInvalidComponent_ReturnsBadRequest() [Fact] public async Task SettingsViewModel_RefreshComponentStatusesAsync_UpdatesInstalledFlags() { - var client = _fixture.CreateClient(); + var jsonResponse = @"[ + { ""id"": ""video-generation"", ""name"": ""Video"", ""installed"": true }, + { ""id"": ""audio-tts"", ""name"": ""Audio"", ""installed"": true } + ]"; + + var handlerMock = new Moq.Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + Moq.Protected.ItExpr.Is(r => r.RequestUri != null && r.RequestUri.ToString().Contains("/api/components")), + Moq.Protected.ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(jsonResponse, System.Text.Encoding.UTF8, "application/json") + }); + + var client = new HttpClient(handlerMock.Object); var vm = new LocalLLMServerManager.Shared.ViewModels.SettingsViewModel(); Assert.False(vm.IsVideoPackInstalled); Assert.False(vm.IsAudioPackInstalled); - await vm.RefreshComponentStatusesAsync("", client); + await vm.RefreshComponentStatusesAsync("http://localhost:5246", client); Assert.True(vm.IsVideoPackInstalled); Assert.True(vm.IsAudioPackInstalled); diff --git a/LocalLLMServerManager.Tests/HuggingFaceSearchViewModelTests.cs b/LocalLLMServerManager.Tests/HuggingFaceSearchViewModelTests.cs new file mode 100644 index 0000000..3208d96 --- /dev/null +++ b/LocalLLMServerManager.Tests/HuggingFaceSearchViewModelTests.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using LocalLLMServerManager.Shared.Interfaces; +using LocalLLMServerManager.Shared.Models; +using LocalLLMServerManager.Shared.Services; +using LocalLLMServerManager.Shared.ViewModels; +using Moq; +using Xunit; + +namespace LocalLLMServerManager.Tests; + +public class HuggingFaceSearchViewModelTests +{ + [Fact] + public void InitialState_DefaultValues_ConfiguredCorrectly() + { + var mockHf = new Mock(); + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + + Assert.Equal("", vm.HfSearchQuery); + Assert.Null(vm.SelectedPipelineTag); + Assert.True(vm.IsFullVramActive); + Assert.True(vm.IsPartialOffloadActive); + Assert.True(vm.IsCpuOnlyActive); + Assert.True(vm.IsOomActive); + Assert.True(vm.IsInputTextActive); + Assert.False(vm.IsInputImageActive); + Assert.True(vm.IsOutputTextActive); + Assert.False(vm.IsHfModalOpen); + Assert.Equal(16384.0, vm.TotalVramMb); + Assert.Equal(32768.0, vm.TotalRamMb); + } + + [Fact] + public void ToggleInputModality_UpdatesFlagsAndList() + { + var mockHf = new Mock(); + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + + vm.ToggleInputModality("Text"); + Assert.False(vm.IsInputTextActive); + Assert.DoesNotContain("Text", vm.SelectedInputModalities); + + vm.ToggleInputModality("Image"); + Assert.True(vm.IsInputImageActive); + Assert.Contains("Image", vm.SelectedInputModalities); + + vm.ToggleInputModality("Audio"); + Assert.True(vm.IsInputAudioActive); + Assert.Contains("Audio", vm.SelectedInputModalities); + + vm.ToggleInputModality("Video"); + Assert.True(vm.IsInputVideoActive); + Assert.Contains("Video", vm.SelectedInputModalities); + } + + [Fact] + public void ToggleOutputModality_UpdatesFlagsAndList() + { + var mockHf = new Mock(); + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + + vm.ToggleOutputModality("Text"); + Assert.False(vm.IsOutputTextActive); + Assert.DoesNotContain("Text", vm.SelectedOutputModalities); + + vm.ToggleOutputModality("Image"); + Assert.True(vm.IsOutputImageActive); + Assert.Contains("Image", vm.SelectedOutputModalities); + + vm.ToggleOutputModality("Audio"); + Assert.True(vm.IsOutputAudioActive); + Assert.Contains("Audio", vm.SelectedOutputModalities); + + vm.ToggleOutputModality("Video"); + Assert.True(vm.IsOutputVideoActive); + Assert.Contains("Video", vm.SelectedOutputModalities); + + vm.ToggleOutputModality("3D"); + Assert.True(vm.IsOutputThreeDActive); + Assert.Contains("3D", vm.SelectedOutputModalities); + } + + [Theory] + [InlineData("llm", true, false, false, false, true, false, false, false, false)] + [InlineData("multimodal", true, true, false, false, true, false, false, false, false)] + [InlineData("image", true, false, false, false, false, true, false, false, false)] + [InlineData("video", true, true, false, false, false, false, false, true, false)] + [InlineData("audio", true, false, true, false, true, false, true, false, false)] + [InlineData("3d", true, true, false, false, false, false, false, false, true)] + public void ApplyPreset_ConfiguresModalitiesProperly( + string preset, + bool expInTxt, bool expInImg, bool expInAud, bool expInVid, + bool expOutTxt, bool expOutImg, bool expOutAud, bool expOutVid, bool expOut3D) + { + var mockHf = new Mock(); + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + + vm.ApplyPreset(preset); + + Assert.Equal(expInTxt, vm.IsInputTextActive); + Assert.Equal(expInImg, vm.IsInputImageActive); + Assert.Equal(expInAud, vm.IsInputAudioActive); + Assert.Equal(expInVid, vm.IsInputVideoActive); + + Assert.Equal(expOutTxt, vm.IsOutputTextActive); + Assert.Equal(expOutImg, vm.IsOutputImageActive); + Assert.Equal(expOutAud, vm.IsOutputAudioActive); + Assert.Equal(expOutVid, vm.IsOutputVideoActive); + Assert.Equal(expOut3D, vm.IsOutputThreeDActive); + } + + [Theory] + [InlineData("Wan-2.1-T2V", "text-to-video", "Video")] + [InlineData("whisper-large-v3", "automatic-speech-recognition", "Audio")] + [InlineData("TRELLIS-image-large", "text-to-3d", "ThreeD")] + [InlineData("FLUX.1-schnell", "text-to-image", "Image")] + [InlineData("Meta-Llama-3.1-8B", "text-generation", "LLM")] + public void DetermineModality_ClassifiesCorrectly(string name, string tag, string expected) + { + var result = HuggingFaceSearchViewModel.DetermineModality(name, tag); + Assert.Equal(expected, result); + } + + [Fact] + public void ToggleFitVerdict_TogglesEachFilter() + { + var mockHf = new Mock(); + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + + vm.ToggleFitVerdict("full"); + Assert.False(vm.IsFullVramActive); + + vm.ToggleFitVerdict("partial"); + Assert.False(vm.IsPartialOffloadActive); + + vm.ToggleFitVerdict("cpu"); + Assert.False(vm.IsCpuOnlyActive); + + vm.ToggleFitVerdict("oom"); + Assert.False(vm.IsOomActive); + } + + [Fact] + public void UpdateHardwareTelemetry_UpdatesVramRamAndRecalculatesBadges() + { + var mockHf = new Mock(); + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + + vm.HuggingFaceResults.Add(new HuggingFaceRepoItem("meta-llama/Llama-3.1-8B", "meta", 50, "10k", "text-generation", null)); + vm.UpdateHardwareTelemetry(8192.0, 16384.0); + + Assert.Equal(8192.0, vm.TotalVramMb); + Assert.Equal(16384.0, vm.TotalRamMb); + Assert.NotNull(vm.HuggingFaceResults[0].FitBadge); + } + + [Fact] + public void NavigateToCanIRunIt_And_InspectModel_InvokeCallback() + { + var mockHf = new Mock(); + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + + string? inspectedModel = null; + string? inspectedModality = null; + + vm.OnInspectModelRequested = (m, mod) => + { + inspectedModel = m; + inspectedModality = mod; + }; + + vm.NavigateToCanIRunIt("black-forest-labs/FLUX.1-dev"); + Assert.Equal("black-forest-labs/FLUX.1-dev", inspectedModel); + Assert.Equal("Image", inspectedModality); + + var repoItem = new HuggingFaceRepoItem("Wan-AI/Wan2.1-T2V", "Wan", 100, "5k", "text-to-video", null); + vm.InspectModel(repoItem); + Assert.Equal("Wan-AI/Wan2.1-T2V", inspectedModel); + Assert.Equal("Video", inspectedModality); + + var fileItem = new HfFileQuantItem("llama-3.1-8b-q4.gguf", "Q4_K_M", "4.0 GB", 4000000000L, null); + vm.InspectQuantFile(fileItem); + Assert.Equal("llama-3.1-8b-q4.gguf", inspectedModel); + Assert.Equal("LLM", inspectedModality); + } + + [Fact] + public async Task SearchHuggingFaceAsync_PopulatesResultsAndAppliesFilter() + { + var mockHf = new Mock(); + mockHf.Setup(s => s.SearchRepositoriesAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new HuggingFaceRepoItem("test-repo/model1", "test-repo", 10, "1k", "text-generation", null) + }); + + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + vm.SelectedPipelineTag = "text-generation"; + + using var client = new HttpClient(); + await vm.SearchHuggingFaceAsync("http://localhost", client); + + Assert.Single(vm.HuggingFaceResults); + Assert.Single(vm.FilteredHuggingFaceResults); + Assert.Equal("test-repo/model1", vm.HuggingFaceResults[0].Id); + } + + [Fact] + public async Task OpenHfModalAsync_And_CloseHfModal_ControlsModalState() + { + var mockHf = new Mock(); + mockHf.Setup(s => s.FetchQuantizationsAsync(It.IsAny(), "TheBloke/Llama-2-7B-GGUF", It.IsAny())) + .ReturnsAsync(new List + { + new HfFileQuantItem("llama-2-7b.Q4_K_M.gguf", "Q4_K_M", "4.0 GB", 4000000000L, null) + }); + + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + + using var client = new HttpClient(); + await vm.OpenHfModalAsync("TheBloke/Llama-2-7B-GGUF", "http://localhost", client); + + Assert.True(vm.IsHfModalOpen); + Assert.Equal("TheBloke/Llama-2-7B-GGUF", vm.ModalRepoId); + Assert.Equal("TheBloke", vm.ModalAuthor); + Assert.Single(vm.ModalHfFiles); + Assert.NotNull(vm.ModalHfFiles[0].FitBadge); + + vm.CloseHfModal(); + Assert.False(vm.IsHfModalOpen); + } +} diff --git a/LocalLLMServerManager.Tests/MainViewModelTests.cs b/LocalLLMServerManager.Tests/MainViewModelTests.cs index 3c2b2fb..802bef7 100644 --- a/LocalLLMServerManager.Tests/MainViewModelTests.cs +++ b/LocalLLMServerManager.Tests/MainViewModelTests.cs @@ -441,5 +441,63 @@ public async Task SaveSettingsAsync_PostsAppSettingsToEndpoint() Assert.Equal("E:\\Models", postedSettings!.ForgeModelsPath); Assert.Equal("http://127.0.0.1:9000", postedSettings.ComfyUiUrl); } + + [Fact] + public void MainViewModel_VideoControls_UpdateStateProperly() + { + var vm = new MainViewModel(new HttpClient()); + var videoItem = new VideoAssetItem("output_001.mp4", "http://localhost:5246/outputs/output_001.mp4", "5.0s", "1280x720", 24, 123456L, 10485760L, DateTime.UtcNow); + + vm.SelectVideo(videoItem); + Assert.Equal("http://localhost:5246/outputs/output_001.mp4", vm.RenderedVideoUrl); + Assert.Equal("5.0s", vm.VideoDurationText); + Assert.Equal("1280x720", vm.VideoResolutionBadge); + Assert.Equal("24 fps", vm.VideoFpsBadge); + Assert.Equal("123456", vm.VideoSeedBadge); + + vm.ToggleVideoPlay(); + Assert.False(vm.IsVideoPlaying); + vm.ToggleVideoPlay(); + Assert.True(vm.IsVideoPlaying); + + vm.ToggleVideoLoop(); + Assert.False(vm.IsVideoLooping); + vm.ToggleVideoLoop(); + Assert.True(vm.IsVideoLooping); + } + + [Fact] + public async Task MainViewModel_LoadGeneratedVideosAsync_ParsesJsonArray() + { + var json = @"[ + { + ""filename"": ""test_video.mp4"", + ""url"": ""/outputs/test_video.mp4"", + ""duration"": ""4.0s"", + ""resolution"": ""832x480"", + ""fps"": 16, + ""seed"": 99999, + ""sizeBytes"": 5242880 + } + ]"; + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.RequestUri!.ToString().Contains("/api/video/files")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(json, Encoding.UTF8, "application/json") }); + + var client = new HttpClient(handlerMock.Object); + var vm = new MainViewModel(client) { ApiBase = "http://127.0.0.1:5246" }; + + await vm.LoadGeneratedVideosAsync(); + + Assert.Single(vm.GeneratedVideosList); + Assert.Equal("test_video.mp4", vm.GeneratedVideosList[0].Filename); + Assert.Equal("http://127.0.0.1:5246/outputs/test_video.mp4", vm.RenderedVideoUrl); + } } diff --git a/LocalLLMServerManager.Tests/OllamaLibraryViewModelTests.cs b/LocalLLMServerManager.Tests/OllamaLibraryViewModelTests.cs new file mode 100644 index 0000000..9876178 --- /dev/null +++ b/LocalLLMServerManager.Tests/OllamaLibraryViewModelTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using LocalLLMServerManager.Shared.Interfaces; +using LocalLLMServerManager.Shared.Models; +using LocalLLMServerManager.Shared.Services; +using LocalLLMServerManager.Shared.ViewModels; +using Moq; +using Moq.Protected; +using Xunit; + +namespace LocalLLMServerManager.Tests; + +public class OllamaLibraryViewModelTests +{ + [Fact] + public void InitialState_DefaultValues_ConfiguredCorrectly() + { + var mockOllama = new Mock(); + var vm = new OllamaLibraryViewModel(mockOllama.Object); + + Assert.True(vm.IsFullVramActive); + Assert.True(vm.IsPartialOffloadActive); + Assert.True(vm.IsCpuOnlyActive); + Assert.True(vm.IsOomActive); + Assert.False(vm.IsDeleteModalOpen); + Assert.False(vm.IsPullDrawerOpen); + Assert.Equal(8192, vm.TargetContextTokens); + Assert.Equal("~0.5 GB", vm.EstimatedKvCacheText); + } + + [Fact] + public void TargetContextTokens_ChangingSlider_UpdatesEstimatedKvCacheText() + { + var mockOllama = new Mock(); + var vm = new OllamaLibraryViewModel(mockOllama.Object); + + vm.TargetContextTokens = 131072; + Assert.Contains("GB", vm.EstimatedKvCacheText); + + vm.TargetContextTokens = 2048; + Assert.Contains("MB", vm.EstimatedKvCacheText); + } + + [Fact] + public void ToggleFitVerdict_TogglesFlagsAndFiltersModels() + { + var mockOllama = new Mock(); + var vm = new OllamaLibraryViewModel(mockOllama.Object); + + vm.ToggleFitVerdict("full"); + Assert.False(vm.IsFullVramActive); + + vm.ToggleFitVerdict("partial"); + Assert.False(vm.IsPartialOffloadActive); + + vm.ToggleFitVerdict("cpu"); + Assert.False(vm.IsCpuOnlyActive); + + vm.ToggleFitVerdict("oom"); + Assert.False(vm.IsOomActive); + } + + [Fact] + public void NavigateToCanIRunIt_And_InspectModel_InvokeCallbacks() + { + var mockOllama = new Mock(); + var vm = new OllamaLibraryViewModel(mockOllama.Object); + + string? inspected = null; + string? modality = null; + vm.OnInspectModelRequested = (m, mod) => + { + inspected = m; + modality = mod; + }; + + vm.NavigateToCanIRunIt("llama3:8b"); + Assert.Equal("llama3:8b", inspected); + Assert.Equal("LLM", modality); + + var modelItem = new OllamaModelItem("mistral:7b", "4.1 GB", "General", "#38BDF8", false); + vm.InspectModel(modelItem); + Assert.Equal("mistral:7b", inspected); + } + + [Fact] + public async Task LoadInstalledModelsAsync_PopulatesCollectionWithBadges() + { + var mockOllama = new Mock(); + mockOllama.Setup(m => m.LoadInstalledModelsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new OllamaModelItem("llama3.1:8b", "4.7 GB", "General", "#38BDF8", false, null, 4700000000L) + }); + + var vm = new OllamaLibraryViewModel(mockOllama.Object); + using var client = new HttpClient(); + await vm.LoadInstalledModelsAsync("http://localhost:5246", client); + + Assert.Single(vm.InstalledModels); + Assert.Single(vm.FilteredInstalledModels); + Assert.NotNull(vm.InstalledModels[0].FitBadge); + } + + [Fact] + public void UpdateHardwareTelemetry_UpdatesVramAndRecalculatesBadges() + { + var mockOllama = new Mock(); + var vm = new OllamaLibraryViewModel(mockOllama.Object); + + vm.InstalledModels.Add(new OllamaModelItem("llama3.1:8b", "4.7 GB", "General", "#38BDF8", false)); + vm.UpdateHardwareTelemetry(8192.0, 16384.0); + + Assert.Equal(8192.0, vm.TotalVramMb); + Assert.Equal(16384.0, vm.TotalRamMb); + Assert.NotNull(vm.InstalledModels[0].FitBadge); + } + + [Fact] + public async Task PullModelAsync_StreamsProgressAndClosesDrawer() + { + var mockOllama = new Mock(); + var vm = new OllamaLibraryViewModel(mockOllama.Object); + + var streamLines = "{\"status\":\"downloading\",\"completed\":500000000,\"total\":1000000000}\n{\"status\":\"verifying sha256 digest\"}\n{\"status\":\"success\"}\n"; + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(streamLines, Encoding.UTF8, "application/json") + }); + + var client = new HttpClient(handlerMock.Object); + await vm.PullModelAsync("deepseek-r1:8b", client); + + Assert.True(vm.IsPullDrawerOpen); + Assert.Equal(50.0, vm.PullProgressPercent); + Assert.Contains("success", vm.PullStatusLog); + + vm.ClosePullDrawer(); + Assert.False(vm.IsPullDrawerOpen); + } +} diff --git a/LocalLLMServerManager.Tests/OllamaModelServiceTests.cs b/LocalLLMServerManager.Tests/OllamaModelServiceTests.cs new file mode 100644 index 0000000..f0f7c14 --- /dev/null +++ b/LocalLLMServerManager.Tests/OllamaModelServiceTests.cs @@ -0,0 +1,289 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using LocalLLMServerManager.Shared.Services; +using Moq; +using Moq.Protected; +using Xunit; + +namespace LocalLLMServerManager.Tests; + +public class OllamaModelServiceTests +{ + [Fact] + public async Task LoadInstalledModelsAsync_PrimaryEndpointSuccess_ParsesModelsCorrectly() + { + var jsonResponse = @"{ + ""models"": [ + { + ""name"": ""llama3:8b"", + ""size"": 4661224576 + }, + { + ""name"": ""qwen2.5-math:7b"", + ""size"": 4661224576 + }, + { + ""name"": ""deepseek-r1:8b"", + ""size"": 4661224576 + }, + { + ""name"": ""unknown-zero"", + ""size"": 0 + } + ] + }"; + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(jsonResponse) + }); + + var client = new HttpClient(handlerMock.Object); + var service = new OllamaModelService(); + + var models = await service.LoadInstalledModelsAsync("http://localhost:5246", client); + + Assert.Equal(4, models.Count); + Assert.Equal("llama3:8b", models[0].Name); + Assert.Equal("4.34 GB", models[0].FormatSize); + Assert.Equal("💻 Coding & General", models[0].CapabilityTag); + + Assert.Equal("qwen2.5-math:7b", models[1].Name); + Assert.Equal("🧮 Mathematics", models[1].CapabilityTag); + + Assert.Equal("deepseek-r1:8b", models[2].Name); + Assert.Equal("🧠 Reasoning profile", models[2].CapabilityTag); + + Assert.Equal("unknown-zero", models[3].Name); + Assert.Equal("N/A", models[3].FormatSize); + } + + [Fact] + public async Task LoadInstalledModelsAsync_PrimaryFails_FallsBackToDirectTags() + { + var tagsResponse = @"{ + ""models"": [ + { + ""name"": ""mistral:latest"", + ""size"": 4100000000 + } + ] + }"; + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.RequestUri != null && r.RequestUri.ToString().Contains("/api/models")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.NotFound }); + + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.RequestUri != null && r.RequestUri.ToString().Contains("/api/tags")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(tagsResponse) + }); + + var client = new HttpClient(handlerMock.Object); + var service = new OllamaModelService(); + + var models = await service.LoadInstalledModelsAsync("http://localhost:5246", client); + + Assert.Single(models); + Assert.Equal("mistral:latest", models[0].Name); + } + + [Fact] + public async Task LoadInstalledModelsAsync_ExceptionThrown_ReturnsEmptyList() + { + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Connection refused")); + + var client = new HttpClient(handlerMock.Object); + var service = new OllamaModelService(); + + var models = await service.LoadInstalledModelsAsync("http://localhost:5246", client); + Assert.Empty(models); + } + + [Fact] + public async Task UnloadAllVramAsync_RunningModelsFound_SendsKeepAliveZero() + { + var psResponse = @"{ + ""models"": [ + { ""name"": ""llama3:8b"" }, + { ""name"": ""phi3:mini"" } + ] + }"; + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.RequestUri != null && r.RequestUri.ToString().Contains("/api/ollama/ps")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(psResponse) + }); + + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.RequestUri != null && r.RequestUri.ToString().Contains("/api/generate")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + + var client = new HttpClient(handlerMock.Object); + var service = new OllamaModelService(); + + var result = await service.UnloadAllVramAsync("http://localhost:5246", client); + Assert.True(result); + } + + [Fact] + public async Task PreloadModelAsync_SendsKeepAliveMinusOne() + { + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.RequestUri != null && r.RequestUri.ToString().Contains("/api/generate")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + + var client = new HttpClient(handlerMock.Object); + var service = new OllamaModelService(); + + var result = await service.PreloadModelAsync("http://localhost:5246", "llama3:8b", client); + Assert.True(result); + } + + [Fact] + public async Task PullModelAsync_ValidAndEmptyInputs_HandledProperly() + { + var service = new OllamaModelService(); + var emptyRes = await service.PullModelAsync(""); + Assert.False(emptyRes); + } + + [Fact] + public async Task DeleteModelAsync_EmptyName_ReturnsFalse() + { + var service = new OllamaModelService(); + var client = new HttpClient(); + var result = await service.DeleteModelAsync("http://localhost:5246", "", client); + Assert.False(result); + } + + [Fact] + public async Task DeleteModelAsync_ServerProxyDelete_Succeeds() + { + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.Method == HttpMethod.Delete && r.RequestUri != null && r.RequestUri.ToString().Contains("/api/models/delete")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + + var client = new HttpClient(handlerMock.Object); + var service = new OllamaModelService(); + + var result = await service.DeleteModelAsync("http://localhost:5246", "llama3:8b", client); + Assert.True(result); + } + + [Fact] + public async Task DeleteModelAsync_ProxyPostFallback_Succeeds() + { + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.Method == HttpMethod.Delete && r.RequestUri != null && r.RequestUri.ToString().Contains("/api/models/delete")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.MethodNotAllowed }); + + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.Method == HttpMethod.Post && r.RequestUri != null && r.RequestUri.ToString().Contains("/api/models/delete")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + + var client = new HttpClient(handlerMock.Object); + var service = new OllamaModelService(); + + var result = await service.DeleteModelAsync("http://localhost:5246", "llama3:8b", client); + Assert.True(result); + } + + [Fact] + public async Task DeleteModelAsync_DirectOllamaDaemonFallback_Succeeds() + { + var handlerMock = new Mock(); + // Proxy DELETE fails + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.RequestUri != null && r.RequestUri.ToString().Contains(":5246")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.NotFound }); + + // Unload generate + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.RequestUri != null && r.RequestUri.ToString().Contains("11434/api/generate")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + + // Direct delete + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.RequestUri != null && r.RequestUri.ToString().Contains("11434/api/delete")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + + var client = new HttpClient(handlerMock.Object); + var service = new OllamaModelService(); + + var result = await service.DeleteModelAsync("http://localhost:5246", "llama3", client); + Assert.True(result); + } + + [Fact] + public async Task DeleteLocalModelFileAsync_ValidatesInputsAndPerformsDelete() + { + var service = new OllamaModelService(); + var client = new HttpClient(); + + var emptyRes = await service.DeleteLocalModelFileAsync("http://localhost:5246", "", client); + Assert.False(emptyRes); + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.Method == HttpMethod.Delete && r.RequestUri != null && r.RequestUri.ToString().Contains("/api/models/delete")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + + var mockClient = new HttpClient(handlerMock.Object); + var successRes = await service.DeleteLocalModelFileAsync("http://localhost:5246", "C:/models/test.safetensors", mockClient); + Assert.True(successRes); + } + + [Fact] + public async Task GetInstalledModelsAsync_ExecutesWithoutThrowing() + { + var service = new OllamaModelService(); + var models = await service.GetInstalledModelsAsync(); + Assert.NotNull(models); + } +} diff --git a/LocalLLMServerManager.Tests/SearchServicesTests.cs b/LocalLLMServerManager.Tests/SearchServicesTests.cs index 8715be9..481eca5 100644 --- a/LocalLLMServerManager.Tests/SearchServicesTests.cs +++ b/LocalLLMServerManager.Tests/SearchServicesTests.cs @@ -145,6 +145,59 @@ public async Task HuggingFaceSearchService_FetchQuantizationsAsync_ParsesFilesRe Assert.Equal("Q8_0", quants[1].Quantization); } + [Fact] + public async Task HuggingFaceSearchService_SearchRepositoriesAsync_MultiTags_CombinesDistinctResults() + { + var jsonResponse = @"[ + { ""id"": ""repo/audio-1"", ""author"": ""meta"", ""likes"": 10, ""downloads"": 100, ""pipeline_tag"": ""text-to-speech"" }, + { ""id"": ""repo/audio-2"", ""author"": ""meta"", ""likes"": 20, ""downloads"": 200, ""pipeline_tag"": ""text-to-audio"" } + ]"; + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(jsonResponse) + }); + + var client = new HttpClient(handlerMock.Object); + var service = new HuggingFaceSearchService(); + + var results = await service.SearchRepositoriesAsync("http://localhost", "query", new[] { "text-to-speech", "text-to-audio" }, client); + Assert.Equal(2, results.Count); + } + + [Fact] + public async Task HuggingFaceSearchService_FetchQuantizationsAsync_ParsesDifferentQuantizations() + { + var jsonResponse = @"{ + ""siblings"": [ + { ""rfilename"": ""model-Q5_K_M.gguf"", ""size"": 5000000000 }, + { ""rfilename"": ""model-FP16.gguf"", ""size"": 16000000000 }, + { ""rfilename"": ""readme.md"", ""size"": 1024 } + ] + }"; + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(jsonResponse) + }); + + var client = new HttpClient(handlerMock.Object); + var service = new HuggingFaceSearchService(); + + var quants = await service.FetchQuantizationsAsync("http://localhost", "org/model", client); + Assert.Equal(2, quants.Count); + Assert.Equal("Q5_K_M", quants[0].Quantization); + Assert.Equal("FP16", quants[1].Quantization); + } + [Fact] public void HuggingFaceSearchViewModel_ResolvePipelineTags_MultimodalVlmReturnsExpectedTags() { @@ -209,4 +262,36 @@ public void CivitaiSearchViewModel_FilterByFitVerdict_FiltersResultsCorrectly() vm.ToggleFitVerdict("oom"); Assert.Equal(2, vm.FilteredCivitaiResults.Count); } + + [Fact] + public async Task SearchServices_EmptyResponses_ReturnEmptyLists() + { + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{ \"items\": [] }") + }); + + var client = new HttpClient(handlerMock.Object); + var civitai = new CivitaiSearchService(); + var civResults = await civitai.SearchModelsAsync("http://localhost", "none", "Checkpoint", "Most Downloaded", client); + Assert.Empty(civResults); + + var hf = new HuggingFaceSearchService(); + var hfResults = await hf.SearchRepositoriesAsync("http://localhost", "query", client); + Assert.Empty(hfResults); + } + + [Fact] + public async Task HuggingFaceSearchService_SearchModelsAsync_ExecutesGracefully() + { + var service = new HuggingFaceSearchService(); + using var cts = new System.Threading.CancellationTokenSource(); + cts.Cancel(); // Cancel immediately to avoid network calls during offline unit tests + var results = await service.SearchModelsAsync("llama", "text-generation", cts.Token); + Assert.Empty(results); + } } diff --git a/LocalLLMServerManager.Tests/ServicesAndEngineManagerTests.cs b/LocalLLMServerManager.Tests/ServicesAndEngineManagerTests.cs index ad97117..8d4d886 100644 --- a/LocalLLMServerManager.Tests/ServicesAndEngineManagerTests.cs +++ b/LocalLLMServerManager.Tests/ServicesAndEngineManagerTests.cs @@ -114,4 +114,39 @@ public async Task AiEngineManager_AudioEngine_StartStop_HandlesGracefully() Assert.True(stopResult.Success); Assert.Equal("audio", stopResult.Engine); } + + [Theory] + [InlineData("forge")] + [InlineData("sdforge")] + [InlineData("comfyui")] + [InlineData("comfy")] + [InlineData("ollama")] + [InlineData("unknown_engine_xyz")] + public async Task AiEngineManager_StartEngineAsync_DispatchesExpectedEngine(string engineName) + { + var manager = new AiEngineManager(); + var result = await manager.StartEngineAsync(engineName); + Assert.NotNull(result); + if (engineName == "unknown_engine_xyz") + { + Assert.False(result.Success); + Assert.Contains("Unsupported", result.Message); + } + } + + [Theory] + [InlineData("forge")] + [InlineData("comfyui")] + [InlineData("unknown_engine_xyz")] + public async Task AiEngineManager_StopEngineAsync_DispatchesExpectedEngine(string engineName) + { + var manager = new AiEngineManager(); + var result = await manager.StopEngineAsync(engineName); + Assert.NotNull(result); + if (engineName == "unknown_engine_xyz") + { + Assert.False(result.Success); + Assert.Contains("Unsupported", result.Message); + } + } } diff --git a/LocalLLMServerManager.Tests/ToolDiscoveryServiceTests.cs b/LocalLLMServerManager.Tests/ToolDiscoveryServiceTests.cs index 65aca8d..d007263 100644 --- a/LocalLLMServerManager.Tests/ToolDiscoveryServiceTests.cs +++ b/LocalLLMServerManager.Tests/ToolDiscoveryServiceTests.cs @@ -137,6 +137,53 @@ public void DetectOllama_WhenInstalledInCustomRoot_DiscoversProperties() Assert.Contains("Ollama", result.StatusMessage); } + [Fact] + public void DetectOllama_WithEnvVarOllamaModels_ResolvesModelsDirectory() + { + var customOllamaDir = Path.Combine(_tempDirectory, "Programs", "Ollama"); + Directory.CreateDirectory(customOllamaDir); + var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ollama.exe" : "ollama"; + var exePath = Path.Combine(customOllamaDir, exeName); + File.WriteAllText(exePath, "fake ollama binary"); + + var envModelsDir = Path.Combine(_tempDirectory, "env_ollama_models"); + Directory.CreateDirectory(envModelsDir); + + var prevEnv = Environment.GetEnvironmentVariable("OLLAMA_MODELS"); + try + { + Environment.SetEnvironmentVariable("OLLAMA_MODELS", envModelsDir); + var service = new ToolDiscoveryService(searchRoots: new[] { _tempDirectory }); + var result = service.DetectOllama(); + + Assert.True(result.IsInstalled); + Assert.Equal(envModelsDir, result.ModelsDirectory); + } + finally + { + Environment.SetEnvironmentVariable("OLLAMA_MODELS", prevEnv); + } + } + + [Fact] + public void DetectAudioEngine_WithCustomVoicesDirectory_DiscoversModelsDir() + { + var kokoroDir = Path.Combine(_tempDirectory, "Kokoro-FastAPI"); + var customVoicesDir = Path.Combine(_tempDirectory, "audio", "custom_voices"); + Directory.CreateDirectory(kokoroDir); + Directory.CreateDirectory(customVoicesDir); + + var mainPy = Path.Combine(kokoroDir, "main.py"); + File.WriteAllText(mainPy, "# test"); + + var service = new ToolDiscoveryService(searchRoots: new[] { _tempDirectory }); + var result = service.DetectAudioEngine(); + + Assert.True(result.IsInstalled); + Assert.Equal(mainPy, result.ExecutablePath); + Assert.Equal(customVoicesDir, result.ModelsDirectory); + } + [Fact] public void DetectOllama_WhenNotFound_ReturnsNotInstalled() {