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;
}
}
}
}