1
0
Fork 0

fix(ui): unify titlebar centerline vertical alignment and eliminate redundant brand container (v1.0.14)

This commit is contained in:
Yun Chan 2026-09-04 23:58:41 +09:00
parent 8eb48ed854
commit 1f794e9e08
14 changed files with 2323 additions and 660 deletions

View file

@ -161,7 +161,6 @@ public class DesignAuditAstTests
var actionsPanel = doc.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "StackPanel" &&
e.Attribute("Grid.Column")?.Value == "1" &&
e.Attribute("Orientation")?.Value == "Horizontal" &&
e.Elements().Any(c => c.Name.LocalName == "Button" && c.Attribute("Command")?.Value?.Contains("SettingsCommand") == true));
@ -234,12 +233,15 @@ public class DesignAuditAstTests
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(mainFile));
// 1. Quick Presets (PresetCommand 바인딩 버튼 존재)
var presetButtons = doc.Descendants()
.Where(e => (e.Name.LocalName == "Button" || e.Name.LocalName == "ToggleButton") &&
e.Attribute("Command")?.Value.Contains("PresetCommand") == true)
.ToList();
Assert.True(presetButtons.Count >= 4, "빠른 최적화 프리셋 버튼 4개(웹/고화질/문서/모바일)가 MainWindow.xaml에 선언되어야 합니다.");
// 1. Dynamic Smart Presets (SmartPresetCombo 및 SmartPresetChipsPanel 존재)
var smartPresetCombo = doc.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "ComboBox" &&
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SmartPresetCombo");
Assert.NotNull(smartPresetCombo);
var specChipsPanel = doc.Descendants()
.FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SmartPresetChipsPanel");
Assert.NotNull(specChipsPanel);
// 2. SearchBox (검색창 존재)
var searchBox = doc.Descendants()
@ -259,4 +261,164 @@ public class DesignAuditAstTests
.ToList();
Assert.NotEmpty(openButtons);
}
[Fact]
public void MainWindow_MustHave_CollapsibleInspectorColumn_And_ToggleCommand()
{
// 우측 미리보기 인스펙터를 원클릭으로 접고 펼쳐 큐 가로폭을 극대화할 수 있도록
// InspectorColumn 명명된 ColumnDefinition과 ToggleInspectorCommand 버튼이 존재해야 한다.
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(mainFile));
var inspectorCol = doc.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "ColumnDefinition" &&
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "InspectorColumn");
Assert.NotNull(inspectorCol);
var toggleBtn = doc.Descendants()
.FirstOrDefault(e => (e.Name.LocalName == "Button" || e.Name.LocalName == "ToggleButton") &&
e.Attribute("Command")?.Value.Contains("ToggleInspectorCommand") == true);
Assert.NotNull(toggleBtn);
}
[Fact]
public void FormatShiftTheme_MustDefine_DoubleBezelAndCardStyles()
{
// Fluent 2 / Dark Instrument 디자인 고도화를 위해
// 8px 그리드 기반의 카드(Double-Bezel) 및 툴바 표준 스타일이 FormatShiftTheme.xaml에 선언되어야 한다.
var themeFile = Path.Combine(ViewsDir, "FormatShiftTheme.xaml");
var doc = XDocument.Parse(File.ReadAllText(themeFile));
var cardStyle = doc.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "Style" &&
e.Attribute(XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "FsCardStyle");
Assert.NotNull(cardStyle);
var toolbarBtnStyle = doc.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "Style" &&
e.Attribute(XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "FsToolbarButtonStyle");
Assert.NotNull(toolbarBtnStyle);
}
[Fact]
public void InspectorHeader_MustHave_DismissOrCloseButton_WithToggleCommand()
{
// 우측 미리보기 인스펙터 헤더에 직관적으로 닫을 수 있는 닫기 버튼이 제공되어야 한다.
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(mainFile));
var inspectorCol = doc.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "Border" &&
e.Attribute("Grid.Column")?.Value == "1" &&
e.Descendants().Any(d => d.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PreviewImageArea"));
Assert.NotNull(inspectorCol);
var header = inspectorCol.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "Border" && e.Attribute("Grid.Row")?.Value == "0");
Assert.NotNull(header);
var closeBtn = header.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "Button" &&
e.Attribute("Command")?.Value.Contains("ToggleInspectorCommand") == true);
Assert.NotNull(closeBtn);
}
[Fact]
public void EmptyStates_MustHave_ActionableCallToActions_And_Shortcuts()
{
// Zero-Void 원칙: DropZone과 PastResults 빈 상태 화면은 단순 텍스트뿐 아니라
// 사용자의 즉각적인 행동을 유도하는 Action 버튼을 포함해야 한다.
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(mainFile));
var dropEmpty = doc.Descendants()
.FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "DropZoneEmpty");
Assert.NotNull(dropEmpty);
var dropCta = dropEmpty.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "Button" &&
e.Attribute("Command")?.Value.Contains("AddFilesCommand") == true);
Assert.NotNull(dropCta);
var pastEmpty = doc.Descendants()
.FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PastResultsEmpty");
Assert.NotNull(pastEmpty);
var pastCta = pastEmpty.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "Button" &&
e.Attribute("Command")?.Value.Contains("TabCommand") == true);
Assert.NotNull(pastCta);
}
[Fact]
public void TopHeader_MustHave_EngineStatusTelemetryBadge()
{
// designpaca R1/R2: 정밀 계측기 상단 툴바에 실시간 엔진 가동 상태 및 프로바이더 텔레메트리 뱃지가 존재해야 한다.
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(mainFile));
var engineTelemetry = doc.Descendants()
.FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "EngineTelemetryBadge");
Assert.NotNull(engineTelemetry);
}
[Fact]
public void FormatShiftTheme_MustDefine_InstrumentTokens_And_CategoryBrushes()
{
// designpaca R2/R3: FormatShiftTheme.xaml에 포맷 카테고리별 시그니처 브러시와 정밀 카드 스타일이 선언되어 있어야 한다.
var themeFile = Path.Combine(ViewsDir, "FormatShiftTheme.xaml");
var doc = XDocument.Parse(File.ReadAllText(themeFile));
var keys = doc.Descendants()
.Select(e => e.Attribute(XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value)
.Where(k => k != null)
.ToHashSet();
Assert.Contains("FsBadgeImageBrush", keys);
Assert.Contains("FsBadgeDocBrush", keys);
Assert.Contains("FsBadgeVideoBrush", keys);
Assert.Contains("FsBadgeAudioBrush", keys);
Assert.Contains("FsInstrumentCardStyle", keys);
}
[Fact]
public void ActiveQueueList_ItemTemplate_MustHave_InstrumentCard_With_CategoryAndTelemetry()
{
// designpaca R1/R3: 대기열 리스트 DataTemplate에 단순 텍스트가 아닌 정밀 계측 카드와 카테고리 뱃지가 구조화되어야 한다.
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(mainFile));
var activeList = doc.Descendants()
.FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ActiveQueueList");
Assert.NotNull(activeList);
var dataTemplate = activeList.Descendants().FirstOrDefault(e => e.Name.LocalName == "DataTemplate");
Assert.NotNull(dataTemplate);
// 카테고리 뱃지 및 모노스페이스 텔레메트리 식별
var hasCategoryPill = dataTemplate.Descendants().Any(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ItemCategoryBadge" ||
e.Attribute("Tag")?.Value == "CategoryPill");
Assert.True(hasCategoryPill, "대기열 카드에 포맷 카테고리 뱃지(ItemCategoryBadge)가 있어야 합니다.");
}
[Fact]
public void SwissMinimal_Tokens_MustInclude_NeonCyanSignatureAccent()
{
// designpaca 사용자 인터뷰 결정: 스위스 미니멀 + 네온 시안(#06B6D4) 시그니처 악센트 토큰 검증
var themeFile = Path.Combine(ViewsDir, "FormatShiftTheme.xaml");
var doc = XDocument.Parse(File.ReadAllText(themeFile));
var cyanBrush = doc.Descendants()
.FirstOrDefault(e => e.Attribute(XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "FsAccentCyan");
Assert.NotNull(cyanBrush);
Assert.Equal("#06B6D4", cyanBrush.Attribute("Color")?.Value);
var cyanBgBrush = doc.Descendants()
.FirstOrDefault(e => e.Attribute(XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "FsAccentCyanBg");
Assert.NotNull(cyanBgBrush);
Assert.Equal("#083344", cyanBgBrush.Attribute("Color")?.Value);
}
}

View file

@ -25,6 +25,8 @@ public class DesignAuditVisualTreeTests
public bool Contains(string key) => _d.ContainsKey(key);
}
private static readonly object AppInitLock = new();
private static void RunOnSta(Action action)
{
Exception? ex = null;
@ -32,9 +34,12 @@ public class DesignAuditVisualTreeTests
{
try
{
if (Application.Current == null)
lock (AppInitLock)
{
_ = new Application();
if (Application.Current == null)
{
try { _ = new Application(); } catch (InvalidOperationException) { }
}
}
action();
}
@ -75,6 +80,32 @@ public class DesignAuditVisualTreeTests
});
}
[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()
{
@ -155,6 +186,69 @@ public class DesignAuditVisualTreeTests
});
}
[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<TextBlock>(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);
});
}
private static IEnumerable<T> FindLogicalChildren<T>(object parent) where T : DependencyObject
{
if (parent is ContentControl cc && cc.Content != null)

View file

@ -0,0 +1,81 @@
using Everything2Everything.App.ViewModels;
using Everything2Everything.Core.Presets;
using Xunit;
namespace Everything2Everything.Tests;
/// <summary>
/// 출력 형식 연동 스마트 프리셋 엔진 TDD 단위 테스트 (RED -> GREEN)
/// </summary>
public class FormatPresetEngineTests
{
[Theory]
[InlineData(".mp3", "최고음질 320k", 320)]
[InlineData(".m4a", "최고음질 320k", 320)]
[InlineData(".aac", "표준 192k", 192)]
public void GetPresetsForExtension_Audio_ReturnsAudioSpecificPresets(string ext, string expectedTitle, int expectedBitrate)
{
var presets = FormatPresetEngine.GetPresetsForExtension(ext);
Assert.NotEmpty(presets);
Assert.Contains(presets, p => p.Title.Contains(expectedTitle));
var targetPreset = presets.First(p => p.Title.Contains(expectedTitle));
Assert.NotEmpty(targetPreset.SpecChips);
var options = new OptionsViewModel();
targetPreset.Apply(options);
Assert.Equal(expectedBitrate, options.AudioBitrateKbps);
}
[Fact]
public void GetPresetsForExtension_WebP_ReturnsImageQualityPresets()
{
var presets = FormatPresetEngine.GetPresetsForExtension(".webp");
Assert.True(presets.Count >= 3);
var q85 = presets.First(p => p.Title.Contains("웹 고화질"));
Assert.Contains("Quality 85", q85.SpecChips);
var options = new OptionsViewModel();
q85.Apply(options);
Assert.Equal(85, options.ImageQuality);
Assert.True(options.StripMetadata);
}
[Fact]
public void GetPresetsForExtension_Pdf_ReturnsResolutionPresets()
{
var presets = FormatPresetEngine.GetPresetsForExtension(".pdf");
Assert.True(presets.Count >= 3);
var print300 = presets.First(p => p.Title.Contains("인쇄용") || p.Title.Contains("300"));
Assert.Contains(print300.SpecChips, c => c.Contains("300 DPI"));
var options = new OptionsViewModel();
print300.Apply(options);
Assert.Equal(100, options.Quality);
}
[Fact]
public void GetPresetsForExtension_Video_ReturnsCrfPresets()
{
var presets = FormatPresetEngine.GetPresetsForExtension(".mp4");
Assert.True(presets.Count >= 3);
var web1080 = presets.First(p => p.Title.Contains("1080p"));
Assert.Contains(web1080.SpecChips, c => c.Contains("1080p") || c.Contains("H.264"));
var options = new OptionsViewModel();
web1080.Apply(options);
Assert.Equal(23, options.VideoCrf);
Assert.Equal("fast", options.VideoPreset);
}
[Fact]
public void GetPresetsForExtension_UnknownExtension_ReturnsFallbackPresets()
{
var presets = FormatPresetEngine.GetPresetsForExtension(".xyz");
Assert.NotEmpty(presets);
Assert.Contains(presets, p => p.Title.Contains("기본"));
}
}

View file

@ -0,0 +1,330 @@
using System.IO;
using System.Linq;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Xml.Linq;
using Everything2Everything.App.Views;
using Everything2Everything.Core;
using Xunit;
namespace Everything2Everything.Tests;
public class UnifiedTitleBarTests
{
private static readonly string SolutionRoot = FindSolutionRoot();
private static readonly string MainWindowXamlPath = Path.Combine(SolutionRoot, "src", "Everything2Everything.App", "Views", "MainWindow.xaml");
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("솔루션 루트를 찾을 수 없습니다.");
}
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);
}
[Fact]
public void MainWindow_MustNotHave_Isolated32PxTitleBarRow()
{
// 32px짜리 고립된 빈 타이틀바 행이 존재하지 않아야 하며,
// 타이틀바와 윈도우 조작 버튼(최소/최대/닫기)이 메인 헤더와 하나의 통합 바로 통합되어야 한다.
var doc = XDocument.Parse(File.ReadAllText(MainWindowXamlPath));
var rootGrid = doc.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "Grid");
Assert.NotNull(rootGrid);
var rowDefs = rootGrid.Element(rootGrid.Name.Namespace + "Grid.RowDefinitions")?.Elements().ToList();
Assert.NotNull(rowDefs);
var has32PxRow = rowDefs.Any(r => r.Attribute("Height")?.Value == "32");
Assert.False(has32PxRow, "최상위 Grid에 32px 분리된 타이틀바 행이 여전히 존재합니다. 헤더와 통합된 단일 바로 구성되어야 합니다.");
}
[Fact]
public void MainWindow_TitleBar_MustBeIntegratedWithTopHeader()
{
// ui:TitleBar 가 분리된 빈 줄이 아니라 통합 헤더 영역(Height >= 48) 내에 배치되어야 한다.
var doc = XDocument.Parse(File.ReadAllText(MainWindowXamlPath));
var titleBar = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "TitleBar");
Assert.NotNull(titleBar);
var gridRow = titleBar.Attribute("Grid.Row")?.Value;
var rootGrid = doc.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "Grid");
var rowDefs = rootGrid?.Element(rootGrid.Name.Namespace + "Grid.RowDefinitions")?.Elements().ToList();
if (gridRow != null && int.TryParse(gridRow, out int rowIndex) && rowDefs != null && rowIndex < rowDefs.Count)
{
var rowHeight = rowDefs[rowIndex].Attribute("Height")?.Value;
Assert.NotEqual("32", rowHeight);
}
}
private static readonly object AppInitLock = new();
[Fact]
public void MainWindow_UnifiedTitleBar_MeasuresAndArrangesCorrectly()
{
Exception? ex = null;
var thread = new Thread(() =>
{
try
{
lock (AppInitLock)
{
if (Application.Current == null)
{
try { _ = new Application(); } catch (InvalidOperationException) { }
}
}
var engine = Everything2EverythingBootstrap.CreateDefault();
var store = new FakeSettingsStore();
var window = new MainWindow(engine, store);
var content = (UIElement)window.Content;
content.Measure(new Size(1280, 960));
content.Arrange(new Rect(0, 0, 1280, 960));
Assert.True(content.DesiredSize.Width > 0);
Assert.True(content.DesiredSize.Height > 0);
var titleBar = FindLogicalChild<Wpf.Ui.Controls.TitleBar>(window);
Assert.NotNull(titleBar);
Assert.True(titleBar.ActualHeight >= 40 || titleBar.DesiredSize.Height >= 40,
$"통합 TitleBar의 높이가 최소 40px 이상이어야 합니다. (실제: {titleBar.DesiredSize.Height})");
}
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_MustNotHave_ClunkyBrandContainer_Or_RedundantTitleText()
{
// 사용자가 불필요하고 이상하다고 지적한 상단 좌측 320px 컨테이너 및 'FormatShift Utility' 문구가 완전히 제거되었는지 정적 AST 검증
var doc = XDocument.Parse(File.ReadAllText(MainWindowXamlPath));
var titleBar = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "TitleBar");
Assert.NotNull(titleBar);
var header = titleBar.Elements().FirstOrDefault(e => e.Name.LocalName == "TitleBar.Header");
Assert.NotNull(header);
var allTextBlocks = header.Descendants().Where(e => e.Name.LocalName == "TextBlock" || e.Name.LocalName == "Run").ToList();
var redundantTexts = allTextBlocks.Where(tb =>
(tb.Attribute("Text")?.Value?.Contains("FormatShift") ?? false) ||
(tb.Attribute("Text")?.Value?.Contains("Utility") ?? false) ||
tb.Value.Contains("FormatShift") || tb.Value.Contains("Utility")).ToList();
Assert.Empty(redundantTexts);
// 320px 너비의 패널 컨테이너 Border가 존재하지 않아야 함
var columnDefs = header.Descendants().Where(e => e.Name.LocalName == "ColumnDefinition").ToList();
var has320Col = columnDefs.Any(c => c.Attribute("Width")?.Value == "320");
Assert.False(has320Col, "상단 바에 320px짜리 투박한 사이드바 헤더 컨테이너가 남아있습니다. 제거되어야 합니다.");
}
[Fact]
public void MainWindow_TopHeader_AllElements_MustSharePreciseVerticalCenterline()
{
// 상단 바 내 모든 요소(최소/최대/닫기 창 버튼, 탭, 엔진 뱃지, 액션 버튼)가
// 시각적으로 들뜨거나 가라앉지 않고 동일한 수직 중심선(Centerline, 오차 2.0px 이내)에 완벽 정렬되어야 한다.
RunOnSta(() =>
{
var engine = Everything2EverythingBootstrap.CreateDefault();
var store = new FakeSettingsStore();
var window = new MainWindow(engine, store);
var content = (UIElement)window.Content;
content.Measure(new Size(1280, 960));
content.Arrange(new Rect(0, 0, 1280, 960));
var titleBar = (Wpf.Ui.Controls.TitleBar)window.FindName("AppTitleBar");
Assert.NotNull(titleBar);
titleBar.ApplyTemplate();
var minBtn = (FrameworkElement?)titleBar.Template.FindName("PART_MinimizeButton", titleBar);
Assert.NotNull(minBtn);
var tabBtn = (FrameworkElement)window.FindName("TabActiveBtn");
var badge = (FrameworkElement)window.FindName("EngineTelemetryBadge");
var clearBtn = (FrameworkElement)window.FindName("ClearAllButton");
// TitleBar 좌표계 기준 각 컨트롤의 수직 중심 Y좌표 계산
double CenterY(FrameworkElement element)
{
var pt = element.TransformToAncestor(titleBar).Transform(new Point(0, element.ActualHeight / 2.0));
return pt.Y;
}
var minCenterY = CenterY(minBtn);
var tabCenterY = CenterY(tabBtn);
var badgeCenterY = CenterY(badge);
var clearCenterY = CenterY(clearBtn);
// 오차 2.0px 이내로 모든 요소가 동일한 수직 기준선상에 정렬되어야 함
Assert.True(Math.Abs(tabCenterY - minCenterY) <= 2.0,
$"최소화 버튼 중심선({minCenterY:F1}px)과 탭 중심선({tabCenterY:F1}px)의 수직 정렬 오차가 너무 큽니다.");
Assert.True(Math.Abs(badgeCenterY - minCenterY) <= 2.0,
$"최소화 버튼 중심선({minCenterY:F1}px)과 엔진 뱃지 중심선({badgeCenterY:F1}px)의 수직 정렬 오차가 너무 큽니다.");
Assert.True(Math.Abs(clearCenterY - minCenterY) <= 2.0,
$"최소화 버튼 중심선({minCenterY:F1}px)과 액션 버튼 중심선({clearCenterY:F1}px)의 수직 정렬 오차가 너무 큽니다.");
try
{
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(1280, 720, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
rtb.Render(content);
var enc = new System.Windows.Media.Imaging.PngBitmapEncoder();
enc.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(rtb));
var outPath = @"C:\Users\encep\.gemini\antigravity\brain\b58fd023-a52b-4f4b-aa23-e6df654aa1fb\.tempmediaStorage\rendered_window.png";
using var fs = File.Create(outPath);
enc.Save(fs);
}
catch { }
});
}
[Fact]
public void MainWindow_HeaderElements_MustBeIntegratedIntoTitleBarHeaderAndTrailingContent_ToPreventNcHitTestBlocking()
{
// Wpf.Ui.Controls.TitleBar의 HwndSourceHook(WM_NCHITTEST)은
// 오직 Header, CenterContent, TrailingContent 내부에 포함된 요소만 HTCLIENT(클릭 허용)로 반환하며,
// 그 외의 형제 요소나 TitleBar 외부 겹침 요소는 모두 HTCAPTION(창 이동 드래그)으로 가로채 클릭을 차단한다.
// 따라서 상단 탭, 뱃지, 액션 버튼들은 반드시 TitleBar.Header와 TitleBar.TrailingContent 내에 직접 배치되어야 한다.
RunOnSta(() =>
{
var engine = Everything2EverythingBootstrap.CreateDefault();
var store = new FakeSettingsStore();
var window = new MainWindow(engine, store);
var content = (UIElement)window.Content;
content.Measure(new Size(1280, 960));
content.Arrange(new Rect(0, 0, 1280, 960));
var tb = (Wpf.Ui.Controls.TitleBar)window.FindName("AppTitleBar");
Assert.NotNull(tb);
Assert.NotNull(tb.Header);
Assert.NotNull(tb.TrailingContent);
var tabActive = window.FindName("TabActiveBtn") as UIElement;
Assert.NotNull(tabActive);
var telemetryBadge = window.FindName("EngineTelemetryBadge") as UIElement;
Assert.NotNull(telemetryBadge);
var clearBtn = window.FindName("ClearAllButton") as UIElement;
Assert.NotNull(clearBtn);
// tabActive와 telemetryBadge가 tb.Header의 자식인지 검증
var headerElement = tb.Header as DependencyObject;
Assert.NotNull(headerElement);
Assert.True(IsDescendantOf(tabActive, headerElement), "TabActiveBtn은 반드시 AppTitleBar.Header 내부에 배치되어야 클릭이 차단되지 않습니다.");
Assert.True(IsDescendantOf(telemetryBadge, headerElement), "EngineTelemetryBadge는 AppTitleBar.Header 내부에 배치되어야 합니다.");
// clearBtn이 tb.TrailingContent의 자식인지 검증
var trailingElement = tb.TrailingContent as DependencyObject;
Assert.NotNull(trailingElement);
Assert.True(IsDescendantOf(clearBtn, trailingElement), "ClearAllButton은 반드시 AppTitleBar.TrailingContent 내부에 배치되어야 클릭이 차단되지 않습니다.");
});
}
[Fact]
public void MainWindow_HeaderLayout_MustBeSpaciousAndNonOverlapping()
{
// 최소 지원 해상도(1080px)에서도 좌측 탭과 우측 액션 버튼이 충돌하지 않도록,
// 우측 액션 툴바(설정, 메뉴, 진단, 로그, 비우기)의 총 가로폭이 260px 이하여야 한다.
RunOnSta(() =>
{
var engine = Everything2EverythingBootstrap.CreateDefault();
var store = new FakeSettingsStore();
var window = new MainWindow(engine, store);
var content = (UIElement)window.Content;
content.Measure(new Size(1080, 720));
content.Arrange(new Rect(0, 0, 1080, 720));
var tb = (Wpf.Ui.Controls.TitleBar)window.FindName("AppTitleBar");
Assert.NotNull(tb);
var trailing = tb.TrailingContent as FrameworkElement;
Assert.NotNull(trailing);
Assert.True(trailing.DesiredSize.Width <= 260,
$"우측 액션 버튼 그룹의 가로 폭이 너무 넓습니다({trailing.DesiredSize.Width}px). 260px 이하로 컴팩트하게 구성되어야 왼쪽 탭과 겹치지 않습니다.");
});
}
private static bool IsDescendantOf(DependencyObject? node, DependencyObject targetAncestor)
{
while (node != null)
{
if (node == targetAncestor) return true;
node = LogicalTreeHelper.GetParent(node) ?? (node as FrameworkElement)?.Parent;
}
return false;
}
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);
}
private static T? FindLogicalChild<T>(DependencyObject parent) where T : DependencyObject
{
foreach (var child in LogicalTreeHelper.GetChildren(parent))
{
if (child is T typed) return typed;
if (child is DependencyObject dep)
{
var found = FindLogicalChild<T>(dep);
if (found != null) return found;
}
}
return null;
}
}