using System.Threading; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Media; using Everything2Everything.App.Views; using Everything2Everything.Core; using Xunit; namespace Everything2Everything.Tests; /// /// 초엄격 Visual & Layout In-Memory 배치 TDD 테스트 스위트 /// STA 스레드에서 창과 컨트롤을 가상 렌더링(Measure & Arrange)하여 /// NaN/Infinity 크기 오류, 요소 클리핑/오버플로, 터치/클릭 최소 타깃 규격을 자동 검증한다. /// public class DesignAuditVisualTreeTests { private sealed class FakeSettingsStore : ISettingsStore { private readonly Dictionary _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); } private static readonly object AppInitLock = new(); private static void RunOnSta(Action action) { Exception? ex = null; var thread = new Thread(() => { try { lock (AppInitLock) { if (Application.Current == null) { try { _ = new Application(); } catch (InvalidOperationException) { } } } action(); } catch (Exception e) { ex = e; } }); thread.SetApartmentState(ApartmentState.STA); thread.Start(); thread.Join(); if (ex != null) { throw new AggregateException("STA 스레드 실행 중 예외 발생", ex); } } [Fact] public void MainWindow_MeasureAndArrange_HasValidLayoutBounds() { RunOnSta(() => { var engine = Everything2EverythingBootstrap.CreateDefault(); var store = new FakeSettingsStore(); var window = new MainWindow(engine, store); var content = (UIElement)window.Content; content.Measure(new Size(1200, 800)); content.Arrange(new Rect(0, 0, 1200, 800)); Assert.False(double.IsNaN(content.DesiredSize.Width)); Assert.False(double.IsNaN(content.DesiredSize.Height)); Assert.True(content.DesiredSize.Width > 0); Assert.True(content.DesiredSize.Height > 0); var buttons = FindLogicalChildren(window).ToList(); Assert.NotEmpty(buttons); }); } [Fact] public void MainWindow_ToggleInspectorCommand_TogglesColumnWidthAndLayout() { RunOnSta(() => { var engine = Everything2EverythingBootstrap.CreateDefault(); var store = new FakeSettingsStore(); var window = new MainWindow(engine, store); Assert.True(window.IsInspectorVisible); // Toggle to collapsed window.ToggleInspectorCommand.Execute(null); Assert.False(window.IsInspectorVisible); var content = (UIElement)window.Content; content.Measure(new Size(1200, 800)); content.Arrange(new Rect(0, 0, 1200, 800)); Assert.True(content.DesiredSize.Width > 0); // Toggle back to expanded window.ToggleInspectorCommand.Execute(null); Assert.True(window.IsInspectorVisible); }); } [Fact] public void DiagnoseWindow_MeasureAndArrange_HasValidLayoutBounds() { RunOnSta(() => { var engine = Everything2EverythingBootstrap.CreateDefault(); var window = new DiagnoseWindow(engine); var content = (UIElement)window.Content; content.Measure(new Size(640, 540)); content.Arrange(new Rect(0, 0, 640, 540)); Assert.False(double.IsNaN(content.DesiredSize.Width)); Assert.False(double.IsNaN(content.DesiredSize.Height)); Assert.False(double.IsInfinity(content.DesiredSize.Width)); Assert.False(double.IsInfinity(content.DesiredSize.Height)); Assert.True(content.DesiredSize.Width > 0); Assert.True(content.DesiredSize.Height > 0); var buttons = FindLogicalChildren(window).ToList(); Assert.NotEmpty(buttons); }); } [Fact] public void QuickOptionsWindow_MeasureAndArrange_HasValidLayoutBounds() { RunOnSta(() => { var store = new FakeSettingsStore(); var window = new QuickOptionsWindow(".mp4", 1, store); var content = (UIElement)window.Content; content.Measure(new Size(380, 500)); content.Arrange(new Rect(0, 0, 380, 500)); Assert.True(content.DesiredSize.Width > 0); Assert.True(content.DesiredSize.Height > 0); // 확인/취소 버튼이 존재하는지 검증 var buttons = FindLogicalChildren(window).ToList(); var actionButtons = buttons.Where(b => b is Button btn && btn.Content is string s && (s == "변환" || s == "취소")).ToList(); Assert.True(actionButtons.Count >= 2); }); } [Fact] public void QuickProgressWindow_MeasureAndArrange_HasValidLayoutBounds() { RunOnSta(() => { using var cts = new CancellationTokenSource(); var window = new QuickProgressWindow(1, cts, ".jpg"); var content = (UIElement)window.Content; content.Measure(new Size(560, 240)); content.Arrange(new Rect(0, 0, 560, 240)); Assert.True(content.DesiredSize.Width > 0); Assert.True(content.DesiredSize.Height > 0); Assert.Equal(560, window.Width); Assert.Equal(240, window.Height); }); } [Fact] public void SettingsWindow_MeasureAndArrange_HasValidLayoutBounds() { RunOnSta(() => { var store = new FakeSettingsStore(); var window = new SettingsWindow(store); var content = (UIElement)window.Content; content.Measure(new Size(560, 720)); content.Arrange(new Rect(0, 0, 560, 720)); Assert.True(content.DesiredSize.Width > 0); Assert.True(content.DesiredSize.Height > 0); Assert.Equal(560, window.Width); Assert.Equal(720, window.Height); }); } [Fact] public void DiagnoseWindow_ProviderCards_MustHaveStructuredGridAlignment_And_TextWrapping() { RunOnSta(() => { var engine = Everything2EverythingBootstrap.CreateDefault(); var window = new DiagnoseWindow(engine); window.PopulateAsync().GetAwaiter().GetResult(); var content = (UIElement)window.Content; content.Measure(new Size(640, 540)); content.Arrange(new Rect(0, 0, 640, 540)); // 1. ItemsPanel 내부에 카드들이 채워졌는지 검증 var itemsPanel = (StackPanel)window.FindName("ItemsPanel"); Assert.NotNull(itemsPanel); Assert.True(itemsPanel.Children.Count >= 2, "환경 섹션 및 프로바이더 카드들이 채워져야 합니다."); // 2. 입력/출력 텍스트가 단일 비정렬 문자열("입력: ... → 출력: ...")로 오버플로되지 않고 // 정형화된 그리드 또는 TextWrapping이 적용된 구조인지 검증 var allTextBlocks = FindLogicalChildren(window).ToList(); var rawUnwrappedOverflows = allTextBlocks.Where(tb => tb.Text.Contains("입력:") && tb.Text.Contains("→") && tb.TextWrapping != TextWrapping.Wrap).ToList(); Assert.Empty(rawUnwrappedOverflows); // 3. 프로바이더 카드 내에 '입력 형식' 및 '출력 형식' 라벨이 명확히 분리 정렬되어 있는지 검증 var inputLabels = allTextBlocks.Where(tb => tb.Text == "입력 형식").ToList(); var outputLabels = allTextBlocks.Where(tb => tb.Text == "출력 형식").ToList(); Assert.NotEmpty(inputLabels); Assert.NotEmpty(outputLabels); // 4. 모든 가로 정렬 헤더(제목 + 뱃지)에서 VerticalAlignment가 Center로 통일되어 있는지 검증 var badges = allTextBlocks.Where(tb => tb.Text is "준비됨" or "개발 중" or "점검 필요" or "비활성").ToList(); Assert.NotEmpty(badges); foreach (var badge in badges) { var border = badge.Parent as Border; Assert.NotNull(border); Assert.Equal(VerticalAlignment.Center, border.VerticalAlignment); } }); } [Fact] public void MainWindow_TopHeader_EngineTelemetryBadge_HasValidBounds_And_CenterAlignment() { RunOnSta(() => { var engine = Everything2EverythingBootstrap.CreateDefault(); var settings = new FakeSettingsStore(); var window = new MainWindow(engine, settings); var content = (UIElement)window.Content; content.Measure(new Size(1280, 960)); content.Arrange(new Rect(0, 0, 1280, 960)); var badge = (Border)window.FindName("EngineTelemetryBadge"); Assert.NotNull(badge); Assert.Equal(VerticalAlignment.Center, badge.VerticalAlignment); Assert.True(badge.DesiredSize.Width > 0); Assert.True(badge.DesiredSize.Height > 0); }); } [Fact] public void MainWindow_WhenQueueIsEmpty_ShowsDefaultFormat_AndQualitySlider_AndAdvancedPanel_Visible() { // Zero-Void 원칙 및 점진적 공개 원칙: 큐가 비어있더라도 사용자가 변환 옵션과 품질 슬라이더를 사전에 확인하고 조작할 수 있도록 // 기본 출력 포맷(.jpg)과 품질 슬라이더, 해당 포맷의 상세 인코딩 서브패널이 Visible 이어야 한다. RunOnSta(() => { var engine = Everything2EverythingBootstrap.CreateDefault(); var settings = new FakeSettingsStore(); var window = new MainWindow(engine, settings); var content = (UIElement)window.Content; content.Measure(new Size(1280, 960)); content.Arrange(new Rect(0, 0, 1280, 960)); var formatCombo = (ComboBox)window.FindName("OutputFormatCombo"); var qualityPanel = (StackPanel)window.FindName("QualityPanel"); var advancedImagePanel = (StackPanel)window.FindName("AdvancedImagePanel"); Assert.NotNull(formatCombo); Assert.NotNull(qualityPanel); Assert.NotNull(advancedImagePanel); // 큐가 비어있어도 지원 가능한 전체 포맷이 채워져 있어야 한다. Assert.True(formatCombo.Items.Count > 0, "큐가 비어있어도 전체 지원 포맷이 표시되어야 합니다."); // 기본 포맷(.jpg)에 맞춰 품질 슬라이더 패널이 표시되어야 한다. Assert.Equal(Visibility.Visible, qualityPanel.Visibility); // 기본 포맷에 맞춰 상세 설정 패널이 표시되어야 한다. Assert.Equal(Visibility.Visible, advancedImagePanel.Visibility); }); } [Fact] public void MainWindow_FormatSwitching_UpdatesQuickPanels_And_AdvancedPanels_Correctly() { // 사용자 피드백 대응: 스마트 프리셋 직하단에서 각 미디어 형식(.mp4, .pdf, .mp3, .webp)에 따라 // 전용 퀵 컨트롤(품질/CRF/비트레이트/PDF압축)과 상세 폴드아웃 서브패널이 동적으로 즉시 표시되어야 한다. RunOnSta(() => { var engine = Everything2EverythingBootstrap.CreateDefault(); var settings = new FakeSettingsStore(); var window = new MainWindow(engine, settings); var content = (UIElement)window.Content; content.Measure(new Size(1280, 960)); content.Arrange(new Rect(0, 0, 1280, 960)); var formatCombo = (ComboBox)window.FindName("OutputFormatCombo"); var qualityPanel = (StackPanel)window.FindName("QualityPanel"); var videoQuickPanel = (StackPanel)window.FindName("VideoQuickPanel"); var audioQuickPanel = (StackPanel)window.FindName("AudioQuickPanel"); var pdfQuickPanel = (StackPanel)window.FindName("PdfQuickPanel"); var expander = (Expander)window.FindName("AdvancedOptionsExpander"); var advVideo = (StackPanel)window.FindName("AdvancedVideoPanel"); var advAudio = (StackPanel)window.FindName("AdvancedAudioPanel"); var advPdf = (StackPanel)window.FindName("AdvancedPdfPanel"); var advImage = (StackPanel)window.FindName("AdvancedImagePanel"); Assert.NotNull(formatCombo); Assert.NotNull(qualityPanel); Assert.NotNull(videoQuickPanel); Assert.NotNull(audioQuickPanel); Assert.NotNull(pdfQuickPanel); Assert.NotNull(expander); // 기본은 열려있어야 함 (사용자가 즉시 확인 가능) Assert.True(expander.IsExpanded); void SelectExtension(string ext) { for (var i = 0; i < formatCombo.Items.Count; i++) { if (formatCombo.Items[i] is ComboBoxItem item && (string)item.Tag == ext) { formatCombo.SelectedIndex = i; break; } } } // 1. 영상 (.mp4) 전환 검증 SelectExtension(".mp4"); Assert.Equal(Visibility.Collapsed, qualityPanel.Visibility); Assert.Equal(Visibility.Visible, videoQuickPanel.Visibility); Assert.Equal(Visibility.Visible, advVideo.Visibility); Assert.Equal(Visibility.Visible, advAudio.Visibility); Assert.Equal(Visibility.Collapsed, advPdf.Visibility); // 2. PDF (.pdf) 전환 검증 SelectExtension(".pdf"); Assert.Equal(Visibility.Collapsed, qualityPanel.Visibility); Assert.Equal(Visibility.Collapsed, videoQuickPanel.Visibility); Assert.Equal(Visibility.Visible, pdfQuickPanel.Visibility); Assert.Equal(Visibility.Visible, advPdf.Visibility); Assert.Equal(Visibility.Collapsed, advVideo.Visibility); // 3. 오디오 (.mp3) 전환 검증 SelectExtension(".mp3"); Assert.Equal(Visibility.Collapsed, qualityPanel.Visibility); Assert.Equal(Visibility.Visible, audioQuickPanel.Visibility); Assert.Equal(Visibility.Visible, advAudio.Visibility); Assert.Equal(Visibility.Collapsed, advVideo.Visibility); // 4. 이미지 (.webp) 전환 검증 SelectExtension(".webp"); Assert.Equal(Visibility.Visible, qualityPanel.Visibility); Assert.Equal(Visibility.Collapsed, videoQuickPanel.Visibility); Assert.Equal(Visibility.Collapsed, audioQuickPanel.Visibility); Assert.Equal(Visibility.Collapsed, pdfQuickPanel.Visibility); Assert.Equal(Visibility.Visible, advImage.Visibility); }); } [Fact] public void MainWindow_RenderToBitmap_SavesVisualVerificationArtifact() { RunOnSta(() => { var dir = System.IO.Directory.GetCurrentDirectory(); while (dir != null && !System.IO.File.Exists(System.IO.Path.Combine(dir, "Everything2Everything.slnx"))) { dir = System.IO.Directory.GetParent(dir)?.FullName; } var root = dir ?? throw new System.IO.DirectoryNotFoundException("솔루션 루트를 찾을 수 없습니다."); var engine = Everything2EverythingBootstrap.CreateDefault(); var settings = new FakeSettingsStore(); var testFiles = new List { System.IO.Path.Combine(root, "test_assets", "test_icon.png"), System.IO.Path.Combine(root, "test_assets", "test_art.png") }; var window = new MainWindow(engine, settings, testFiles); window.ApplyTemplate(); var expander = (Expander)window.FindName("AdvancedOptionsExpander"); expander.ApplyTemplate(); var content = (UIElement)window.Content; content.Measure(new Size(1280, 960)); content.Arrange(new Rect(0, 0, 1280, 960)); content.UpdateLayout(); var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(1280, 960, 96, 96, PixelFormats.Pbgra32); rtb.Render(content); 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"); using (var fs = new System.IO.FileStream(outPath, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.ReadWrite)) { encoder.Save(fs); } Assert.True(System.IO.File.Exists(outPath)); Assert.True(new System.IO.FileInfo(outPath).Length > 1000); }); } private static IEnumerable FindLogicalChildren(object parent) where T : DependencyObject { if (parent is ContentControl cc && cc.Content != null) { if (cc.Content is T t) yield return t; foreach (var c in FindLogicalChildren(cc.Content)) yield return c; } if (parent is DependencyObject dep) { foreach (var rawChild in LogicalTreeHelper.GetChildren(dep)) { if (rawChild is T directMatch) yield return directMatch; foreach (var grandChild in FindLogicalChildren(rawChild)) yield return grandChild; } } } }