1
0
Fork 0

feat(tools): add first-run external tool installer wizard and catalog

첫 실행 시 외부 도구 설치 마법사를 띄우고 7종(FFmpeg·LibreOffice·Pandoc·ImageMagick·H2Orestart·agy·Codex)을 기본 전체 선택 상태로 순차 설치한다.
- Core: ExternalToolCatalog(정적 카탈로그, winget ID 실검증), ExternalToolInstaller(winget 무인 실행·재감지 판정, IExternalCommandRunner 시드로 테스트 가능)
- Core: ExternalToolDetector에 winget FFmpeg 스캔 + Pandoc/ImageMagick 감지 추가
- App: ToolSetupWindow + ToolSetupViewModel, 첫 실행 플래그(setup.tools.done), 설정 창 '외부 도구 설치/검증' 재진입 버튼
- Tests: ExternalToolInstallerTests 5건
- 기존 미커밋 작업(광고 Ads 지원: AdBannerControl/AdLargeCardControl/AdService, AI 백엔드 agy·switchboard·codex 채팅 클라이언트, 관련 테스트) 포함
This commit is contained in:
Yun Chan 2026-09-07 21:16:49 +09:00
parent f00f6567f4
commit 6a41c4a0dc
37 changed files with 2545 additions and 69 deletions

View file

@ -0,0 +1,105 @@
using System.IO;
using System.Linq;
using System.Xml.Linq;
using Xunit;
namespace Everything2Everything.Tests;
public class AdDesignAuditTests
{
private static readonly string SolutionRoot = FindSolutionRoot();
private static readonly string ViewsDir = Path.Combine(SolutionRoot, "src", "Everything2Everything.App", "Views");
private static string FindSolutionRoot()
{
var dir = Directory.GetCurrentDirectory();
while (dir != null && !File.Exists(Path.Combine(dir, "Everything2Everything.slnx")))
{
dir = Directory.GetParent(dir)?.FullName;
}
return dir ?? throw new DirectoryNotFoundException("솔루션 루트를 찾을 수 없습니다.");
}
[Fact]
public void AdControls_MustExistAndBeWellFormed()
{
var bannerPath = Path.Combine(ViewsDir, "AdBannerControl.xaml");
var largeCardPath = Path.Combine(ViewsDir, "AdLargeCardControl.xaml");
Assert.True(File.Exists(bannerPath), "AdBannerControl.xaml 이 존재해야 합니다.");
Assert.True(File.Exists(largeCardPath), "AdLargeCardControl.xaml 이 존재해야 합니다.");
var bannerDoc = XDocument.Parse(File.ReadAllText(bannerPath));
var largeCardDoc = XDocument.Parse(File.ReadAllText(largeCardPath));
Assert.NotNull(bannerDoc.Root);
Assert.NotNull(largeCardDoc.Root);
}
[Fact]
public void AdControls_MustComplyWithStrictDesignAudit()
{
var targetFiles = new[]
{
Path.Combine(ViewsDir, "AdBannerControl.xaml"),
Path.Combine(ViewsDir, "AdLargeCardControl.xaml")
};
var rawEmojis = new[] { "⚙", "📁", "🗑", "✖", "🔧", "✨", "🚀", "⚡" };
var violations = new List<string>();
foreach (var file in targetFiles)
{
if (!File.Exists(file)) continue;
var doc = XDocument.Parse(File.ReadAllText(file));
var fileName = Path.GetFileName(file);
// 1. 유니코드 이모지 금지 검증
var buttons = doc.Descendants().Where(e => e.Name.LocalName is "Button" or "ToggleButton");
foreach (var btn in buttons)
{
var content = btn.Attribute("Content")?.Value;
if (!string.IsNullOrEmpty(content) && rawEmojis.Any(emoji => content.Contains(emoji)))
{
violations.Add($"[{fileName}] 버튼에 이모지 사용 감지: {content}");
}
}
// 2. 가로 StackPanel 내 아이콘/텍스트 VerticalAlignment="Center" 검증
var stackPanels = doc.Descendants().Where(e => e.Name.LocalName == "StackPanel" &&
e.Attribute("Orientation")?.Value == "Horizontal");
foreach (var sp in stackPanels)
{
foreach (var child in sp.Elements())
{
var va = child.Attribute("VerticalAlignment")?.Value;
if (va != "Center")
{
violations.Add($"[{fileName}] 가로 StackPanel의 자식 <{child.Name.LocalName}>에 VerticalAlignment=\"Center\" 누락");
}
}
}
// 3. TextBlock 오버플로 방지 (TextTrimming 또는 TextWrapping) 검증
var textBlocks = doc.Descendants().Where(e => e.Name.LocalName == "TextBlock");
foreach (var tb in textBlocks)
{
var text = tb.Attribute("Text")?.Value ?? "";
// 바인딩된 동적 텍스트는 Trimming이나 Wrapping 필수
if (text.Contains("{Binding") || text.Contains("{x:Bind"))
{
var trimming = tb.Attribute("TextTrimming")?.Value;
var wrapping = tb.Attribute("TextWrapping")?.Value;
if (string.IsNullOrEmpty(trimming) && string.IsNullOrEmpty(wrapping))
{
violations.Add($"[{fileName}] 바인딩 TextBlock에 TextTrimming 또는 TextWrapping 누락: {text}");
}
}
}
}
Assert.True(violations.Count == 0,
"광고 컨트롤 디자인 감사 위반이 발견되었습니다:\n" + string.Join("\n", violations));
}
}

View file

@ -0,0 +1,30 @@
using Everything2Everything.Core.Ads;
using Xunit;
namespace Everything2Everything.Tests;
public class AdMobConfigTests
{
[Fact]
public void AdMobConfig_DefaultState_IsValid()
{
var config = new AdMobConfig();
Assert.NotNull(config.AppId);
Assert.NotNull(config.BannerAdUnitId);
Assert.NotNull(config.InterstitialAdUnitId);
}
[Fact]
public void GenerateAppAdsTxt_ReturnsValidGoogleSpecification()
{
var config = new AdMobConfig
{
PublisherId = "pub-1234567890123456"
};
string appAdsTxt = config.GenerateAppAdsTxt();
Assert.Contains("google.com, pub-1234567890123456, DIRECT, f08c47fec0942fa0", appAdsTxt);
}
}

View file

@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Everything2Everything.Core.Ads;
using Xunit;
namespace Everything2Everything.Tests;
public class AdServiceTests
{
[Fact]
public void GetBannerAds_ReturnsDefaultHouseAds_NotEmpty()
{
var service = new AdService();
var bannerAds = service.GetBannerAds();
Assert.NotNull(bannerAds);
Assert.NotEmpty(bannerAds);
Assert.All(bannerAds, ad =>
{
Assert.Equal(AdType.Banner, ad.Type);
Assert.False(string.IsNullOrWhiteSpace(ad.Title));
Assert.False(string.IsNullOrWhiteSpace(ad.Description));
Assert.False(string.IsNullOrWhiteSpace(ad.TargetUrl));
});
}
[Fact]
public void GetLargeCardAds_ReturnsDefaultLargeCardAds_NotEmpty()
{
var service = new AdService();
var largeAds = service.GetLargeCardAds();
Assert.NotNull(largeAds);
Assert.NotEmpty(largeAds);
Assert.All(largeAds, ad =>
{
Assert.Equal(AdType.LargeCard, ad.Type);
Assert.False(string.IsNullOrWhiteSpace(ad.Title));
Assert.False(string.IsNullOrWhiteSpace(ad.Description));
Assert.False(string.IsNullOrWhiteSpace(ad.TargetUrl));
});
}
[Fact]
public void GetNextBannerAd_RotatesThroughAds()
{
var service = new AdService();
var banners = service.GetBannerAds();
if (banners.Count > 1)
{
var first = service.GetNextBannerAd();
var second = service.GetNextBannerAd();
Assert.NotNull(first);
Assert.NotNull(second);
Assert.NotEqual(first.Id, second.Id);
}
else
{
var ad = service.GetNextBannerAd();
Assert.NotNull(ad);
}
}
[Fact]
public void OpenAdUrl_InvalidOrEmptyUrl_ReturnsFalse()
{
var service = new AdService();
Assert.False(service.OpenAdUrl(new AdItem { TargetUrl = "" }));
Assert.False(service.OpenAdUrl(new AdItem { TargetUrl = "javascript:alert(1)" }));
Assert.False(service.OpenAdUrl(new AdItem { TargetUrl = "not-a-valid-url" }));
}
[Fact]
public void LoadAdsFromJson_ValidJson_PopulatesCustomAds()
{
var service = new AdService();
string json = @"[
{
""Id"": ""test-banner-1"",
""Title"": "" "",
""Description"": "" "",
""BadgeText"": """",
""ImageUrl"": ""https://example.com/banner.png"",
""TargetUrl"": ""https://example.com/promo"",
""CtaText"": "" "",
""Type"": 0,
""IsActive"": true
},
{
""Id"": ""test-large-1"",
""Title"": "" "",
""Description"": "" "",
""BadgeText"": ""AD"",
""ImageUrl"": ""https://example.com/large.png"",
""TargetUrl"": ""https://example.com/b2b"",
""CtaText"": "" "",
""Type"": 1,
""IsActive"": true
}
]";
int count = service.LoadAdsFromJson(json);
Assert.Equal(2, count);
var banners = service.GetBannerAds();
var largeCards = service.GetLargeCardAds();
Assert.Contains(banners, b => b.Id == "test-banner-1");
Assert.Contains(largeCards, l => l.Id == "test-large-1");
}
}

View file

@ -0,0 +1,102 @@
using System.Collections.Generic;
using Everything2Everything.App.ViewModels;
using Everything2Everything.Core.Ads;
using Xunit;
namespace Everything2Everything.Tests;
public class AdViewModelTests
{
private class FakeAdService : IAdService
{
public List<AdItem> Banners { get; } = new()
{
new AdItem { Id = "b1", Title = "배너 1", TargetUrl = "https://example.com/b1", Type = AdType.Banner },
new AdItem { Id = "b2", Title = "배너 2", TargetUrl = "https://example.com/b2", Type = AdType.Banner }
};
public List<AdItem> Cards { get; } = new()
{
new AdItem { Id = "c1", Title = "카드 1", TargetUrl = "https://example.com/c1", Type = AdType.LargeCard },
new AdItem { Id = "c2", Title = "카드 2", TargetUrl = "https://example.com/c2", Type = AdType.LargeCard }
};
public AdItem? LastOpenedAd { get; private set; }
private int _bIndex = 0;
private int _cIndex = 0;
public IReadOnlyList<AdItem> GetBannerAds() => Banners;
public IReadOnlyList<AdItem> GetLargeCardAds() => Cards;
public AdItem? GetNextBannerAd()
{
var ad = Banners[_bIndex % Banners.Count];
_bIndex++;
return ad;
}
public AdItem? GetNextLargeCardAd()
{
var ad = Cards[_cIndex % Cards.Count];
_cIndex++;
return ad;
}
public System.Threading.Tasks.Task<int> RefreshAdsFromFeedAsync(string feedUrl, System.Threading.CancellationToken cancellationToken = default) => System.Threading.Tasks.Task.FromResult(0);
public int LoadAdsFromJson(string json) => 0;
public bool OpenAdUrl(AdItem ad)
{
LastOpenedAd = ad;
return true;
}
}
[Fact]
public void Constructor_InitializesAdsAndVisibility()
{
var fake = new FakeAdService();
var vm = new AdViewModel(fake);
Assert.NotNull(vm.CurrentBannerAd);
Assert.NotNull(vm.CurrentLargeAd);
Assert.True(vm.IsBannerVisible);
Assert.True(vm.IsLargeCardVisible);
}
[Fact]
public void ClickBannerAdCommand_OpensAdUrl()
{
var fake = new FakeAdService();
var vm = new AdViewModel(fake);
vm.ClickBannerAdCommand.Execute(null);
Assert.NotNull(fake.LastOpenedAd);
Assert.Equal(vm.CurrentBannerAd?.Id, fake.LastOpenedAd.Id);
}
[Fact]
public void DismissBannerCommand_HidesBanner()
{
var fake = new FakeAdService();
var vm = new AdViewModel(fake);
Assert.True(vm.IsBannerVisible);
vm.DismissBannerCommand.Execute(null);
Assert.False(vm.IsBannerVisible);
}
[Fact]
public void RotateBannerAd_ChangesCurrentBanner()
{
var fake = new FakeAdService();
var vm = new AdViewModel(fake);
var firstId = vm.CurrentBannerAd?.Id;
vm.RotateBannerAd();
var secondId = vm.CurrentBannerAd?.Id;
Assert.NotEqual(firstId, secondId);
}
}

View file

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Everything2Everything.Core;
using Everything2Everything.Core.Converters;
using Xunit;
namespace Everything2Everything.Tests;
public class AgyChatClientTests
{
private sealed class FakeStore : ISettingsStore
{
private readonly Dictionary<string, string> _d = new();
public string? Get(string key) => _d.TryGetValue(key, out var v) ? v : null;
public void Set(string key, string value) => _d[key] = value;
public void Remove(string key) => _d.Remove(key);
public bool Contains(string key) => _d.ContainsKey(key);
}
[Fact]
public void ExternalToolDetector_CanDetectAgy()
{
var available = ExternalToolDetector.IsAgyAvailable(out var path);
if (!available) return;
Assert.False(string.IsNullOrWhiteSpace(path));
Assert.EndsWith("agy.exe", path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AgyChatClient_HasCorrectName()
{
var client = new AgyChatClient();
Assert.Equal("Antigravity CLI (agy)", client.Name);
}
[Fact]
public void LlmProvider_ResolveClient_AutoSelectsAgyWhenNoKey()
{
var store = new FakeStore();
var provider = new LlmProvider(store);
var (client, model) = provider.ResolveClientForTesting(new AiOptions { Backend = "agy" });
Assert.NotNull(client);
Assert.IsType<AgyChatClient>(client);
}
[Fact]
public async Task LlmProvider_CheckAvailability_IsReadyWhenAgyAvailable()
{
if (!ExternalToolDetector.IsAgyAvailable(out _)) return;
var store = new FakeStore();
var provider = new LlmProvider(store);
var availability = await provider.CheckAvailabilityAsync();
Assert.True(availability.IsReady);
}
}

View file

@ -26,6 +26,7 @@ public class DependencyInjectionTests
Assert.NotNull(sp.GetRequiredService<ProviderRegistry>());
Assert.NotNull(sp.GetRequiredService<ConversionEngine>());
Assert.NotNull(sp.GetRequiredService<Everything2Everything.Core.Ads.IAdService>());
}
[Fact]

View file

@ -944,8 +944,8 @@ public class DesignAuditAstTests
Assert.NotNull(searchBoxCol);
var widthVal = searchBoxCol.Attribute("Width")?.Value;
Assert.True(int.TryParse(widthVal, out var w) && w <= 180,
$"SearchBox 컬럼 폭은 180px 이하여야 필터 버튼들이 잘리지 않습니다. 현재: {widthVal}");
Assert.True(widthVal == "*" || (int.TryParse(widthVal, out var w) && w <= 180),
$"SearchBox 컬럼 폭은 가변(*)이거나 180px 이하여야 필터 컨트롤이 잘리지 않습니다. 현재: {widthVal}");
}
[Fact]
@ -1104,6 +1104,33 @@ public class DesignAuditAstTests
Assert.Equal("0", row);
Assert.Equal("2", rowSpan);
}
[Fact]
public void MainWindow_Toolbar_MustHave_FlexibleSearch_And_CategoryFilterDropdown_Without_Overflow()
{
// 파일명 검색창과 카테고리 필터가 좁은 창 가로폭(360px)에서도 씹힘/겹침(Overflow Collision)이 발생하지 않도록
// 1) 5개 개별 가로 버튼 대신 단일 폴드아웃 드롭다운(CategoryFilterCombo)으로 정합되어야 하며
// 2) 검색창은 Width="*" 컬럼에 위치하여 가변 너비를 가져야 하고
// 3) CategoryFilterCombo는 VerticalAlignment="Center"를 준수해야 한다.
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(file));
// CategoryFilterCombo ComboBox 존재 검증
var combo = doc.Descendants().FirstOrDefault(e =>
e.Name.LocalName == "ComboBox" &&
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "CategoryFilterCombo");
Assert.NotNull(combo);
var vAlign = combo.Attribute("VerticalAlignment")?.Value;
Assert.Equal("Center", vAlign);
// 이전 5개 개별 가로 버튼(CommandParameter="All" 등)이 툴바에서 제거되어 드롭다운으로 대체되었는지 검증
var individualFilterButtons = doc.Descendants().Where(e =>
e.Name.LocalName == "Button" &&
e.Attribute("Command")?.Value?.Contains("FilterCategoryCommand") == true &&
e.Attribute("CommandParameter")?.Value is "All" or "Image" or "Document" or "Media" or "Data").ToList();
Assert.Empty(individualFilterButtons);
}
}

View file

@ -5,6 +5,7 @@ using System.Windows.Controls.Primitives;
using System.Windows.Media;
using Everything2Everything.App.Views;
using Everything2Everything.Core;
using Everything2Everything.Core.Filters;
using Xunit;
namespace Everything2Everything.Tests;
@ -398,8 +399,9 @@ public class DesignAuditVisualTreeTests
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(rtb));
var outDir = @"C:\Users\encep\.gemini\antigravity\brain\a5abaf02-dd4b-45f7-8890-144e9da36bcc";
var outPath = System.IO.Path.Combine(outDir, "app_rendered_preview.png");
var outDir = @"C:\Users\encep\.gemini\antigravity\brain\b58fd023-a52b-4f4b-aa23-e6df654aa1fb";
if (!System.IO.Directory.Exists(outDir)) System.IO.Directory.CreateDirectory(outDir);
var outPath = System.IO.Path.Combine(outDir, "rendered_toolbar_fixed.png");
using (var fs = new System.IO.FileStream(outPath, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.ReadWrite))
{
encoder.Save(fs);
@ -410,6 +412,86 @@ public class DesignAuditVisualTreeTests
});
}
[Fact]
public void MainWindow_CategoryFilterCombo_ChangesCategory_And_FiltersQueue()
{
RunOnSta(() =>
{
var engine = Everything2EverythingBootstrap.CreateDefault();
var settings = new FakeSettingsStore();
var window = new MainWindow(engine, settings);
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\test.png", FileName = "test.png" });
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\doc.pdf", FileName = "doc.pdf" });
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\clip.mp4", FileName = "clip.mp4" });
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\sheet.xlsx", FileName = "sheet.xlsx" });
var combo = (ComboBox)window.FindName("CategoryFilterCombo");
Assert.NotNull(combo);
Assert.Equal(5, combo.Items.Count);
// 0: 전체 (All)
Assert.Equal(FilterCategory.All, window.SelectedCategory);
// 1: 이미지 (Image)
combo.SelectedIndex = 1;
Assert.Equal(FilterCategory.Image, window.SelectedCategory);
var view = System.Windows.Data.CollectionViewSource.GetDefaultView(window.ActiveQueue);
var filteredItems = view.Cast<QueueItem>().ToList();
Assert.Single(filteredItems);
Assert.Equal("test.png", filteredItems[0].FileName);
// 0: 전체 복귀 (All)
combo.SelectedIndex = 0;
Assert.Equal(FilterCategory.All, window.SelectedCategory);
filteredItems = view.Cast<QueueItem>().ToList();
Assert.Equal(4, filteredItems.Count);
// 가상 레이아웃 검증 (너비 360px 환경에서도 DesiredSize가 정상 계산되는지)
var content = (UIElement)window.Content;
content.Measure(new Size(360, 600));
content.Arrange(new Rect(0, 0, 360, 600));
Assert.True(content.DesiredSize.Width > 0);
Assert.False(double.IsNaN(content.DesiredSize.Width));
});
}
[Fact]
public void AdBannerControl_MeasureAndArrange_HasValidLayoutBounds()
{
RunOnSta(() =>
{
var adService = new Everything2Everything.Core.Ads.AdService();
var vm = new Everything2Everything.App.ViewModels.AdViewModel(adService);
var banner = new AdBannerControl { DataContext = vm };
banner.Measure(new Size(800, 100));
banner.Arrange(new Rect(0, 0, 800, 100));
Assert.True(banner.DesiredSize.Width > 0);
Assert.True(banner.DesiredSize.Height > 0);
Assert.False(double.IsNaN(banner.DesiredSize.Width));
Assert.False(double.IsNaN(banner.DesiredSize.Height));
});
}
[Fact]
public void AdLargeCardControl_MeasureAndArrange_HasValidLayoutBounds()
{
RunOnSta(() =>
{
var adService = new Everything2Everything.Core.Ads.AdService();
var vm = new Everything2Everything.App.ViewModels.AdViewModel(adService);
var card = new AdLargeCardControl { DataContext = vm };
card.Measure(new Size(340, 300));
card.Arrange(new Rect(0, 0, 340, 300));
Assert.True(card.DesiredSize.Width > 0);
Assert.True(card.DesiredSize.Height > 0);
Assert.False(double.IsNaN(card.DesiredSize.Width));
Assert.False(double.IsNaN(card.DesiredSize.Height));
});
}
private static IEnumerable<T> FindLogicalChildren<T>(object parent) where T : DependencyObject
{
if (parent is ContentControl cc && cc.Content != null)

View file

@ -3,6 +3,7 @@ using System.IO;
using System.Linq;
using Everything2Everything.App.ViewModels;
using Everything2Everything.Core;
using Everything2Everything.Core.Converters;
using Everything2Everything.Core.Providers;
using Everything2Everything.Core.Filters;
using Xunit;
@ -147,5 +148,56 @@ public class E2EUserScenarioTests
Assert.False(MediaConversionNegotiator.CanConvert(".xlsx", ".png"));
}
}
[Theory]
[InlineData("summarize", null, "요약")]
[InlineData("translate", "일본어", "일본어")]
[InlineData("proofread", null, "교정")]
public void Scenario8_AiQuickPanel_Options_BuildPrompt_FormatsCorrectlyForAllTasks(string task, string? targetLang, string expectedKeyword)
{
var options = new AiOptions { Task = task, TargetLanguage = targetLang };
var (system, user) = LlmProvider.BuildPrompt(options, "테스트 입력 문장");
Assert.Contains(expectedKeyword, system);
Assert.Equal("테스트 입력 문장", user);
}
[Fact]
public async Task Scenario9_AiConversion_EndToEnd_SwitchboardResolution()
{
var tempDir = Path.Combine(Path.GetTempPath(), "E2E_AiConversion_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var srcFile = Path.Combine(tempDir, "sample.md");
await File.WriteAllTextAsync(srcFile, "# Hello\nEverything2Everything test content.");
var store = new FakeSettingsStore();
store.Set("switchboard.endpoint", "http://192.168.0.225:8787");
var provider = new LlmProvider(store);
var options = new ConvertOptions
{
Ai = new AiOptions { Task = "summarize", Backend = "switchboard" }
};
var (client, _) = provider.ResolveClientForTesting(options.Ai);
Assert.NotNull(client);
Assert.Equal("Switchboard Gateway", client.Name);
var sbClient = Assert.IsType<SwitchboardChatClient>(client);
Assert.Equal("http://192.168.0.225:8787", sbClient.Endpoint);
}
finally
{
if (Directory.Exists(tempDir))
Directory.Delete(tempDir, true);
}
}
private sealed class FakeSettingsStore : ISettingsStore
{
private readonly Dictionary<string, string> _d = new();
public string? Get(string key) => _d.TryGetValue(key, out var v) ? v : null;
public void Set(string key, string value) => _d[key] = value;
public void Remove(string key) => _d.Remove(key);
public bool Contains(string key) => _d.ContainsKey(key);
}
}

View file

@ -0,0 +1,117 @@
using Everything2Everything.App.ViewModels;
using Everything2Everything.Core.Converters;
using Xunit;
namespace Everything2Everything.Tests;
public class ExternalToolInstallerTests
{
[Fact]
public void Catalog_CoversAllKnownTools_WithVerifiedWingetIds()
{
var byKey = ExternalToolCatalog.All.ToDictionary(t => t.Key, StringComparer.OrdinalIgnoreCase);
Assert.Equal(7, byKey.Count);
Assert.Equal("Gyan.FFmpeg", byKey["ffmpeg"].WingetId);
Assert.Equal("TheDocumentFoundation.LibreOffice", byKey["libreoffice"].WingetId);
Assert.Equal("JohnMacFarlane.Pandoc", byKey["pandoc"].WingetId);
Assert.Equal("ImageMagick.ImageMagick", byKey["imagemagick"].WingetId);
Assert.Equal("Google.AntigravityCLI", byKey["agy"].WingetId);
Assert.Equal("OpenAI.Codex", byKey["codex"].WingetId);
Assert.Equal(ExternalToolInstallKind.Manual, byKey["h2orestart"].Kind);
Assert.False(string.IsNullOrWhiteSpace(byKey["h2orestart"].ManualNote));
}
[Fact]
public async Task InstallAsync_WingetTool_RunsSilentWinget_AndReportsInstalled()
{
var runner = new CapturingRunner();
var installer = new ExternalToolInstaller(runner);
var tool = WingetTool("test-tool", "Contoso.Demo", () => true);
var result = await installer.InstallAsync(tool, CancellationToken.None);
Assert.Equal("winget", runner.LastFileName);
Assert.Contains("--id Contoso.Demo", runner.LastArgs);
Assert.Contains("--silent", runner.LastArgs);
Assert.Contains("--accept-package-agreements", runner.LastArgs);
Assert.Contains("--accept-source-agreements", runner.LastArgs);
Assert.Contains("--disable-interactivity", runner.LastArgs);
Assert.True(result.Success);
Assert.Null(result.Message);
}
[Fact]
public async Task InstallAsync_WhenStillNotDetectedAfterRun_ReportsFailure()
{
var runner = new CapturingRunner { ExitCode = 1, Output = "installer failed" };
var installer = new ExternalToolInstaller(runner);
var tool = WingetTool("test-tool", "Contoso.Demo", () => false);
var result = await installer.InstallAsync(tool, CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("실패", result.Message);
Assert.Equal(1, result.ExitCode);
}
[Fact]
public async Task InstallAsync_ManualKind_DoesNotRunAnything_ReturnsGuidance()
{
var runner = new CapturingRunner();
var installer = new ExternalToolInstaller(runner);
var tool = new ExternalToolDefinition
{
Key = "manual",
DisplayName = "Manual",
Description = "d",
Kind = ExternalToolInstallKind.Manual,
ManualNote = "직접 추가하세요",
IsInstalled = () => false,
};
var result = await installer.InstallAsync(tool, CancellationToken.None);
Assert.Null(runner.LastFileName);
Assert.False(result.Success);
Assert.Equal("직접 추가하세요", result.Message);
}
[Fact]
public void ToolSetupViewModel_DefaultsToAllSelected_AndCoversCatalog()
{
var vm = new ToolSetupViewModel(
ExternalToolCatalog.All,
new ExternalToolInstaller(new CapturingRunner()));
Assert.Equal(ExternalToolCatalog.All.Count, vm.Tools.Count);
Assert.All(vm.Tools, t => Assert.True(t.IsSelected));
Assert.True(vm.SelectAll);
}
private static ExternalToolDefinition WingetTool(string key, string id, Func<bool> installed) => new()
{
Key = key,
DisplayName = key,
Description = key,
Kind = ExternalToolInstallKind.Winget,
WingetId = id,
IsInstalled = installed,
};
private sealed class CapturingRunner : IExternalCommandRunner
{
public string? LastFileName;
public string? LastArgs;
public int ExitCode { get; set; }
public string Output { get; set; } = "";
public Task<ExternalCommandResult> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct)
{
LastFileName = fileName;
LastArgs = arguments;
return Task.FromResult(new ExternalCommandResult(ExitCode, Output));
}
}
}

View file

@ -25,8 +25,8 @@ public class LlmProviderTests
[Fact]
public async Task NoKey_IsNotReady()
{
// 환경변수 키 또는 codex CLI(OAuth)가 있으면 AI가 활성화되므로 이 단언은 건너뜀
if (EnvHasKey() || ExternalToolDetector.IsCodexAvailable()) return;
// 환경변수 키 또는 codex/agy CLI(OAuth) 또는 Switchboard Gateway가 있으면 AI가 활성화되므로 이 단언은 건너뜀
if (EnvHasKey() || ExternalToolDetector.IsCodexAvailable() || ExternalToolDetector.IsAgyAvailable(out _) || ExternalToolDetector.IsSwitchboardGatewayAvailable(out _)) return;
var p = new LlmProvider(new FakeStore());
var a = await p.CheckAvailabilityAsync();
Assert.False(a.IsReady);

View file

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Everything2Everything.Core;
using Everything2Everything.Core.Converters;
using Xunit;
namespace Everything2Everything.Tests;
public class SwitchboardChatClientTests
{
private sealed class FakeStore : ISettingsStore
{
private readonly Dictionary<string, string> _d = new();
public string? Get(string key) => _d.TryGetValue(key, out var v) ? v : null;
public void Set(string key, string value) => _d[key] = value;
public void Remove(string key) => _d.Remove(key);
public bool Contains(string key) => _d.ContainsKey(key);
}
[Fact]
public void SwitchboardChatClient_HasCorrectNameAndEndpoint()
{
var client = new SwitchboardChatClient();
Assert.Equal("Switchboard Gateway", client.Name);
Assert.Equal("http://127.0.0.1:8787", client.Endpoint);
}
[Fact]
public void SwitchboardChatClient_CustomEndpoint_IsPreserved()
{
var client = new SwitchboardChatClient("http://192.168.1.100:8787/");
Assert.Equal("http://192.168.1.100:8787", client.Endpoint);
}
[Fact]
public void LlmProvider_ResolveClient_SelectsSwitchboardWhenRequested()
{
var store = new FakeStore();
store.Set("switchboard.endpoint", "http://127.0.0.1:8787");
var provider = new LlmProvider(store);
var (client, model) = provider.ResolveClientForTesting(new AiOptions { Backend = "switchboard" });
Assert.NotNull(client);
Assert.IsType<SwitchboardChatClient>(client);
}
[Fact]
public async Task CheckSwitchboardGatewayHealthAsync_ReturnsFalseForUnreachablePort()
{
var (available, _, _) = await ExternalToolDetector.CheckSwitchboardGatewayHealthAsync("http://127.0.0.1:59999");
Assert.False(available);
}
[Fact]
public void ExternalToolDetector_IsSwitchboardGatewayAvailable_DoesNotThrow()
{
var available = ExternalToolDetector.IsSwitchboardGatewayAvailable(out var ep);
Assert.Equal("http://127.0.0.1:8787", ep);
// Returns bool without hanging or throwing
}
[Fact]
public void LlmProvider_CustomEndpoint_IsUsedWhenConfigured()
{
var store = new FakeStore();
store.Set("switchboard.endpoint", "http://192.168.0.25:8787");
var provider = new LlmProvider(store);
var (client, _) = provider.ResolveClientForTesting(new AiOptions { Backend = "switchboard" });
Assert.NotNull(client);
var sbClient = Assert.IsType<SwitchboardChatClient>(client);
Assert.Equal("http://192.168.0.25:8787", sbClient.Endpoint);
}
[Fact]
public void ExternalToolDetector_IsSwitchboardGatewayAvailable_WithCustomEndpoint_PreservesEndpoint()
{
var available = ExternalToolDetector.IsSwitchboardGatewayAvailable(out var ep, "http://192.168.0.25:8787/");
Assert.Equal("http://192.168.0.25:8787", ep);
Assert.False(available);
}
}