diff --git a/design.md b/design.md deleted file mode 100644 index d847604..0000000 --- a/design.md +++ /dev/null @@ -1,88 +0,0 @@ -# design.md — Everything2Everything Windows 11 Flagship Acrylic & Neo Glass Design System - -`designpaca` 스튜디오 파이프라인(0~6단계)과 사용자 인터뷰를 완벽히 준수하여 Everything2Everything의 시각 언어, 레이아웃 그리드, 큐 항목 카드, 컨트롤 덱을 개편한 디자인 시스템 SSOT 문서입니다. - ---- - -## 1. 브리프 게이트 (0단계: 사용자 인터뷰 확정) -- **무엇을**: Everything2Everything (.NET 9 WPF) 올인원 파일 변환 유틸리티의 UI/UX를 Windows 11 플래그십 아크릴 & 네오 글래스(Fluent 2) 스타일로 전면 개편하고, 선택된 포맷에 맞춰 동적으로 동작하는 스마트 프리셋 시스템을 구현. -- **누구에게**: 대량의 미디어·문서·오디오를 다루며 세련된 감각과 정밀한 변환 옵션, 실시간 스펙(비트레이트, DPI, 품질, 코덱) 피드백을 원하는 크리에이터 및 전문가. -- **제약**: .NET 9 WPF (`Wpf.Ui` FluentWindow), 10초 이내 전체 테스트 스위트 100% 통과(TDD 절대주의), 8px 그리드 시스템 준수, 기본 로케일 ko-KR 한국어 표준화. -- **사용자 인터뷰 확정 결정사항**: - 1. **톤앤매너**: **`Windows 11 플래그십 아크릴 & 네오 글래스`** (부드러운 반투명 블러 깊이감, 네오모피즘 소프트 섀도우, 둥근 알약 캡슐 컨트롤, 은은한 앰비언트 글로우). 기존의 밋밋한 다크 박스 스타일을 완전히 탈피. - 2. **프리셋 시스템**: **`출력 형식 연동 스마트 프리셋 드롭다운 + 스펙 칩`** (출력 형식 선택 시 전용 프리셋이 드롭다운으로 반응하고, 하단에 [320 kbps] [48 kHz] 등의 스펙 칩이 실시간 동기화). 기존의 무관한 고정 4개 버튼 완전 퇴출. - 3. **레이아웃**: **`좌측 사이드바 전면 재편 + 모던 글래스 카드 리스트`** (포맷과 동적 프리셋을 하나의 '스마트 변환 덱'으로 유기적 통합하고, 중앙 작업 큐 행을 입체적인 글래스 카드로 강화). - ---- - -## 2. 레퍼런스 조사 및 6축 해체 (1단계) -| 슬롯 | 대상 및 출처 | 가져온 핵심 원리 (6축 기반) | -|---|---|---| -| **R1 (구조)** | **Windows 11 Fluent 2 & Apple macOS Media Converter** | **유기적 포맷-프리셋 바인딩**: 출력 형식을 고르면 그 포맷의 특화 옵션과 프리셋이 자동으로 하위 컴포넌트에 주입되는 반응형 덱 구조. | -| **R2 (톤)** | **Windows 11 Acrylic & Neo Glassmorphism** | **빛과 깊이의 위계**: 흑요석 아크릴 베이스(`rgba(10, 12, 16, 0.9)`), 카드 상단 림라이트 보더(`LinearGradient` 14% -> 4%), 부드러운 앰비언트 소프트 섀도우(Blur 16, Opacity 0.35), 둥근 알약 캡슐(Pill CornerRadius 14~16px). | -| **R3 (디테일)** | **Teenage Engineering & Ableton Live / Final Cut Pro** | **실시간 기술 스펙 칩 (Live Spec Chips)**: 선택된 프리셋의 핵심 매개변수(비트레이트, 샘플레이트, DPI, 해상도, 코덱)를 모노스페이스 캡슐 뱃지로 즉각 시각화. | - ---- - -## 3. 방향 결정 (2단계) -> **"단순한 어두운 창이 아닌, 빛과 유리가 깃든 최신 윈도우 11 플래그십 마스터링 덱"** - -- **Aesthetic Direction**: Windows 11 Flagship Acrylic & Neo Glass -- **감수한 리스크 (The One Risk Taken)**: - - 기존의 평평한 단색 박스를 버리고, **부드러운 아크릴 심도와 네오 섀도우, 림라이트 그라디언트 보더를 도입하여 깊이감 있는 3D 글래스 서피스를 전면 배치**. -- **표면 다이얼**: - - 표현성: 3 (플루언트 2 글래스모피즘, 정교한 캡슐, 빛과 그림자) - - 정보 밀도: 3 (동적 스펙 칩 및 세부 파라미터 시각화) - - 모션: 2 (부드러운 호버 글로우 및 유연한 콤보박스 인터랙션) - - 텔레메트리: 3 (절감률 게이지, 상태 뱃지, 실시간 스펙 칩) - ---- - -## 4. 디자인 토큰 체계 (3단계) - -### 4.1 색상 및 글래스 토큰 (`FormatShiftTheme.xaml`) -* `FsBgBase`: `#0A0C10` (심해 흑요석 베이스) -* `FsBgPanel`: `#11141C` (반투명 아크릴 사이드바/헤더) -* `FsBgSurface`: `#171B26` (네오 글래스 카드 배경) -* `FsBgSurfaceHover`: `#202535` (호버 시 은은한 아크릴 밝기) -* `FsBorderSubtle`: `LinearGradientBrush` (상단 `rgba(255,255,255,0.14)`, 하단 `rgba(255,255,255,0.04)`) - 상단 광원 림라이트 효과 -* `FsBorderStrong`: `rgba(255, 255, 255, 0.20)` (포커스 및 악센트 경계) -* `FsAccentCyan`: `#06B6D4` (사이버네틱 네온 시안 시그니처) -* `FsAccentCyanGlow`: `#22D3EE` (소프트 글로우 림) - -### 4.2 알약 캡슐 컨트롤 (Pill Capsules) -* **메인 탭 (Active Queue / Past Results)**: `CornerRadius="16"`, `Height="32"`, 선택 시 시안 글로우 언더라인 및 하이라이트. -* **필터 칩**: `CornerRadius="14"`, 패딩 `10,4`, 아크릴 반투명 배경. -* **스펙 칩 (Live Spec Chips)**: `CornerRadius="6"`, 패딩 `8,3`, JetBrains Mono 11px, 은은한 시안 틴트 배경. - ---- - -## 5. 실측 검증 및 게이트 계획 (5단계) -* TDD 절대주의: 실패하는 RED 테스트(`FormatPresetEngineTests`) 선행 작성. -* 10초 이내 전체 테스트 100% 통과 (목표 실행 시간 1.5초 이내). -- **15대 하드 게이트 통과 내역**: - 1. 토큰 밖 인라인 색상/폰트 없음: 통과 (100% 리소스 참조) - 2. 한 페이지 내 다중 강조색 없음: 통과 (네온 시안 단일 중심, 상태색/카테고리 분리) - 3. 일관된 border-radius: 통과 (Pill 14/20px, Card 6/8px, Tag 3/4px) - 4. 페이지 중간 테마 반전 없음: 통과 (일관된 Swiss Minimal 톤 유지) - 5. 불필요한 가로 스크롤 없음: 통과 (2~3열 반응형 그리드 및 TextWrapping) - 6. 버튼 라벨 2줄 접힘 없음: 통과 (수직 중앙 정렬 및 여유 폭 확보) - 7. 텍스트-배경 대비 4.5:1 이상: 통과 (최소 7.8:1, 최대 15.2:1, 버튼 9.8:1로 WCAG AAA 충족) - 8. 비정형 속성 애니메이션 배제: 통과 - 9. 모바일/데스크톱 뷰포트 오버플로 없음: 통과 - 10. 불필요한 이벤트 리스너 배제: 통과 - 11. 가짜 통계 수치 배제: 통과 (기본값 정직한 0 노출) - 12. 가짜 스크린샷 배제: 통과 - 13. 브랜드 표면 정합성: 통과 - 14. 미사용 죽은 스타일/코드 배제: 통과 - 15. 제3언어 클리셰 배제 및 한국어 표준화: 통과 (100% 정제된 한국어 표기) - ---- - -## 6. 의도적으로 하지 않은 것과 그 이유 (6단계) -1. **화려한 보라/파랑 그라디언트 및 드롭 섀도우 배제**: - - 이유: 스위스 미니멀 원칙에 따라 거짓 입체감(그림자, 그라디언트) 대신 정직한 1px 경계선과 네온 시안으로 선명한 정렬과 위계를 구축함. -2. **이모지 인라인 혼용 및 영문 라벨 배제**: - - 이유: 아마추어 슬롭 패턴을 방지하고, 모든 액션 및 버튼을 정제된 벡터 아이콘과 표준 한국어로 통일함. -3. **애니메이션 지연(Delay) 배제**: - - 이유: 고속 일괄 작업 도구로서 즉각적인 반응성(`0.15s`)을 보장하기 위해 연출용 딜레이를 일체 넣지 않음. diff --git a/packaging/Everything2Everything.iss b/packaging/Everything2Everything.iss index 3bad288..9f487e6 100644 --- a/packaging/Everything2Everything.iss +++ b/packaging/Everything2Everything.iss @@ -1,6 +1,6 @@ ; Everything2Everything Inno Setup Script #ifndef MyAppVersion -#define MyAppVersion "1.0.14" +#define MyAppVersion "1.0.8" #endif #define MyAppName "Everything2Everything" diff --git a/packaging/Package.appxmanifest b/packaging/Package.appxmanifest index 2dc9e51..07347e7 100644 --- a/packaging/Package.appxmanifest +++ b/packaging/Package.appxmanifest @@ -1,6 +1,6 @@ - + - + Everything2Everything YunChan diff --git a/src/Everything2Everything.App/Everything2Everything.App.csproj b/src/Everything2Everything.App/Everything2Everything.App.csproj index 48e53ff..41bf7c6 100644 --- a/src/Everything2Everything.App/Everything2Everything.App.csproj +++ b/src/Everything2Everything.App/Everything2Everything.App.csproj @@ -1,7 +1,7 @@ - 1.0.11 + 1.0.8 WinExe net9.0-windows10.0.19041.0 enable diff --git a/src/Everything2Everything.App/Views/DiagnoseWindow.xaml b/src/Everything2Everything.App/Views/DiagnoseWindow.xaml index 3e10c7a..29d1e71 100644 --- a/src/Everything2Everything.App/Views/DiagnoseWindow.xaml +++ b/src/Everything2Everything.App/Views/DiagnoseWindow.xaml @@ -3,46 +3,30 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="진단" - Width="680" Height="580" - MinWidth="560" MinHeight="480" + Width="640" Height="540" ExtendsContentIntoTitleBar="True" - WindowBackdropType="None" + WindowBackdropType="Mica" WindowCornerPreference="Round" WindowStartupLocation="CenterOwner"> - - - - - - - - - + - - + + - - - - - - - - - - - - - + @@ -166,360 +64,387 @@ BorderThickness="0,0,1,0"> + + + + + + + FormatShift + + + + + - + - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - + - - - @@ -730,56 +745,39 @@ - - + Fill="#04FFFFFF"/> - + + Text="Drop files anywhere to queue"/> - - - - - - - - - - - - - - - + Style="{StaticResource FsSecondaryButtonStyle}" + Padding="18,8" + Content="Browse…" + Command="{Binding AddFilesCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> + @@ -790,8 +788,11 @@ ItemsSource="{Binding ActiveQueue, RelativeSource={RelativeSource AncestorType=Window}}"> - - + @@ -800,15 +801,15 @@ - + - + - + CornerRadius="6,0,0,6"/> + @@ -822,28 +823,16 @@ - - - - - - - - - + @@ -873,6 +862,15 @@ + + + + + + + @@ -885,13 +883,13 @@ - - + - + Fill="#04FFFFFF"/> + @@ -903,17 +901,6 @@ HorizontalAlignment="Center" TextAlignment="Center" Text="파일을 변환하면 완료된 내역과 절감된 용량이 여기에 기록됩니다"/> - @@ -946,12 +933,14 @@ - + - + @@ -959,11 +948,11 @@ - + - + @@ -971,25 +960,15 @@ RenderOptions.BitmapScalingMode="HighQuality" Source="{Binding GlyphSource}"/> - - - - - - - + - - - - - - + + + + @@ -1062,27 +1036,17 @@ - - - - - - - - + + + + @@ -1244,4 +1208,5 @@ + diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs index edc932e..57b3635 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml.cs +++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs @@ -70,24 +70,6 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow public ICommand BatchSelectAllCommand { get; } public ICommand BatchRemoveSelectedCommand { get; } public ICommand BatchClearCompletedCommand { get; } - public ICommand ToggleInspectorCommand { get; } - - private bool _isInspectorVisible = true; - public bool IsInspectorVisible - { - get => _isInspectorVisible; - set - { - if (_isInspectorVisible != value) - { - _isInspectorVisible = value; - if (InspectorColumn != null) - { - InspectorColumn.Width = _isInspectorVisible ? new GridLength(380) : new GridLength(0); - } - } - } - } private string _searchText = ""; public string SearchText @@ -142,7 +124,6 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow BatchSelectAllCommand = new RelayCommand(p => BatchSelectAll(p)); BatchRemoveSelectedCommand = new RelayCommand(_ => BatchRemoveSelected()); BatchClearCompletedCommand = new RelayCommand(_ => BatchClearCompleted()); - ToggleInspectorCommand = new RelayCommand(_ => ToggleInspector()); RemoveQueueItemCommand = new RelayCommand(p => RemoveQueueItem(p as QueueItem)); OpenFolderCommand = new RelayCommand(p => OpenFolderForPath(p as string)); TabCommand = new RelayCommand(p => ShowTab(p as string ?? "Past")); @@ -157,11 +138,6 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow InitializeComponent(); - if (SmartPresetCombo is not null) - { - SmartPresetCombo.SelectionChanged += OnSmartPresetChanged; - } - // ActiveQueueList/PastResultsList의 ItemsSource는 XAML이 ActiveQueue/PastResults에 바인딩(선언적). InitializeOutputFormats(); @@ -244,11 +220,6 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow UpdatePastResultsVisibility(); } - public void ToggleInspector() - { - IsInspectorVisible = !IsInspectorVisible; - } - // ============== Drag & Drop ============== private void HandleDragOver(DragEventArgs? e) @@ -346,12 +317,12 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow var count = _activeQueue.Count; if (_cts is not null) { - ProcessQueueButton.Content = $"변환 처리 중… ({count}개 파일)"; + ProcessQueueButton.Content = $"Processing… ({count} files)"; ProcessQueueButton.IsEnabled = false; } else if (count == 0) { - ProcessQueueButton.Content = "대기 중 — 파일을 드래그하여 추가하세요"; + ProcessQueueButton.Content = "Idle — drop files to begin"; ProcessQueueButton.IsEnabled = false; } else if (string.IsNullOrEmpty(SelectedOutputExtension)) @@ -361,7 +332,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow } else { - ProcessQueueButton.Content = $"대기열 일괄 변환 시작 ({count}개) [Ctrl + Enter]"; + ProcessQueueButton.Content = $"Process Queue ({count})"; ProcessQueueButton.IsEnabled = true; } } @@ -606,107 +577,6 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow } } - private IReadOnlyList _currentFormatPresets = Array.Empty(); - private bool _suppressPresetChanged; - - private void UpdateSmartPresetsForFormat(string? ext) - { - if (SmartPresetCombo is null) return; - - _currentFormatPresets = FormatPresetEngine.GetPresetsForExtension(ext); - _suppressPresetChanged = true; - try - { - SmartPresetCombo.Items.Clear(); - foreach (var preset in _currentFormatPresets) - { - SmartPresetCombo.Items.Add(new ComboBoxItem - { - Content = preset.Title, - Tag = preset.Id, - ToolTip = preset.Description, - }); - } - if (SmartPresetCombo.Items.Count > 0) - { - SmartPresetCombo.SelectedIndex = 0; - } - } - finally - { - _suppressPresetChanged = false; - } - - ApplySelectedSmartPreset(); - } - - private void OnSmartPresetChanged(object sender, SelectionChangedEventArgs e) - { - if (_suppressPresetChanged) return; - ApplySelectedSmartPreset(); - } - - private void ApplySelectedSmartPreset() - { - if (SmartPresetCombo is null || SmartPresetCombo.SelectedIndex < 0) return; - if (SmartPresetCombo.SelectedIndex >= _currentFormatPresets.Count) return; - - var preset = _currentFormatPresets[SmartPresetCombo.SelectedIndex]; - preset.Apply(_options); - - if (SmartPresetDescriptionText is not null) - { - SmartPresetDescriptionText.Text = preset.Description; - } - - if (SmartPresetChipsPanel is not null) - { - SmartPresetChipsPanel.Children.Clear(); - var chipStyle = TryFindResource("FsSpecChipStyle") as Style; - var monoFont = TryFindResource("FsFontMono") as FontFamily; - var cyanBrush = TryFindResource("FsAccentCyan") as Brush ?? Brushes.Cyan; - - foreach (var chip in preset.SpecChips) - { - var border = new Border(); - if (chipStyle is not null) - { - border.Style = chipStyle; - } - else - { - border.CornerRadius = new CornerRadius(6); - border.Padding = new Thickness(8, 3, 8, 3); - border.Margin = new Thickness(0, 0, 6, 6); - } - - var tb = new TextBlock - { - Text = chip, - FontSize = 11, - Foreground = cyanBrush, - VerticalAlignment = VerticalAlignment.Center, - }; - if (monoFont is not null) - { - tb.FontFamily = monoFont; - } - - border.Child = tb; - SmartPresetChipsPanel.Children.Add(border); - } - } - - if (QualitySlider is not null) - { - QualitySlider.Value = _options.Quality; - } - if (QualityValueText is not null) - { - QualityValueText.Text = _options.Quality.ToString(CultureInfo.InvariantCulture) + "%"; - } - } - private void ApplyFilterCategory(string? categoryName) { if (Enum.TryParse(categoryName, true, out var cat)) @@ -1319,7 +1189,6 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow UpdateOutputDestHint(keepExt); UpdateCombineState(keepExt); UpdateProcessQueueButton(); - UpdateSmartPresetsForFormat(keepExt); if (OutputFormatHint is not null) { @@ -1364,7 +1233,6 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow UpdateOutputFormatBadge(ext); UpdateQualityPanelForFormat(ext); UpdateOutputDestHint(ext); - UpdateSmartPresetsForFormat(ext); } } diff --git a/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml b/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml index ba98883..8adf646 100644 --- a/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml +++ b/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml @@ -20,7 +20,7 @@ - + diff --git a/src/Everything2Everything.App/Views/QuickProgressWindow.xaml b/src/Everything2Everything.App/Views/QuickProgressWindow.xaml index 637fd15..f4d5a7a 100644 --- a/src/Everything2Everything.App/Views/QuickProgressWindow.xaml +++ b/src/Everything2Everything.App/Views/QuickProgressWindow.xaml @@ -22,7 +22,7 @@ - + diff --git a/src/Everything2Everything.Core/Presets/FormatPresetEngine.cs b/src/Everything2Everything.Core/Presets/FormatPresetEngine.cs deleted file mode 100644 index d7ea16c..0000000 --- a/src/Everything2Everything.Core/Presets/FormatPresetEngine.cs +++ /dev/null @@ -1,374 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Everything2Everything.Core.Presets; - -/// -/// 출력 형식에 맞춰 동적으로 제공되는 세부 프리셋 정보 및 기술 스펙 -/// -public sealed record FormatPreset( - string Id, - string Title, - string Description, - IReadOnlyList SpecChips, - Action ApplyAction) -{ - public void Apply(dynamic options) => ApplyAction(options); -} - -/// -/// 확장자별 최적화 프리셋을 공급하는 스마트 프리셋 엔진 (TDD SSOT) -/// -public static class FormatPresetEngine -{ - public static IReadOnlyList GetPresetsForExtension(string? outputExtension) - { - var ext = (outputExtension ?? "").Trim().ToLowerInvariant(); - if (!ext.StartsWith('.') && !string.IsNullOrEmpty(ext)) ext = "." + ext; - - return ext switch - { - ".mp3" => Mp3Presets(), - ".m4a" or ".aac" => AacPresets(ext), - ".flac" or ".wav" => LosslessAudioPresets(ext), - ".ogg" or ".opus" => OggOpusPresets(ext), - - ".webp" => WebpPresets(), - ".avif" => AvifPresets(), - ".jpg" or ".jpeg" => JpgPresets(), - ".png" => PngPresets(), - - ".pdf" => PdfPresets(), - - ".mp4" => Mp4Presets(), - ".mkv" or ".webm" or ".mov" or ".avi" => GeneralVideoPresets(ext), - - ".csv" or ".xlsx" or ".json" => DataPresets(ext), - ".docx" or ".html" or ".md" or ".txt" => DocumentPresets(ext), - - _ => FallbackPresets(ext) - }; - } - - private static IReadOnlyList Mp3Presets() => new List - { - new( - "mp3-high-320", - "최고음질 320k (스튜디오 마스터링)", - "CBR 320kbps · 48kHz · 고해상도 음악 및 마스터링 보존", - new[] { "320 kbps", "48 kHz", "CBR", "스테레오" }, - opt => { opt.AudioBitrateKbps = 320; opt.AudioVbr = false; opt.SampleRateIndex = 2; opt.ChannelsIndex = 2; } - ), - new( - "mp3-standard-192", - "표준 스트리밍 192k (일반 감상)", - "VBR 192kbps · 44.1kHz · 음질과 용량의 최적 균형", - new[] { "192 kbps", "44.1 kHz", "VBR", "스테레오" }, - opt => { opt.AudioBitrateKbps = 192; opt.AudioVbr = true; opt.SampleRateIndex = 1; opt.ChannelsIndex = 2; } - ), - new( - "mp3-voice-128", - "팟캐스트·음성 128k (보이스 최적화)", - "CBR 128kbps · 모노 다운믹스 · 라우드니스 정규화", - new[] { "128 kbps", "모노", "음성 선명화", "Loudnorm" }, - opt => { opt.AudioBitrateKbps = 128; opt.AudioVbr = false; opt.ChannelsIndex = 1; opt.Loudnorm = true; } - ), - new( - "mp3-compact-64", - "초절약 압축 64k (용량 극소화)", - "CBR 64kbps · 32kHz · 녹음본 보관 및 가벼운 전송", - new[] { "64 kbps", "모노", "초경량 보관" }, - opt => { opt.AudioBitrateKbps = 96; opt.AudioVbr = false; opt.ChannelsIndex = 1; } - ) - }; - - private static IReadOnlyList AacPresets(string ext) => new List - { - new( - "aac-high-320", - "최고음질 320k (Apple/고음질)", - $"{ext.ToUpperInvariant()} 고비트레이트 · 48kHz · 투명한 음질", - new[] { "320 kbps", "48 kHz", "AAC LC", "스테레오" }, - opt => { opt.AudioBitrateKbps = 320; opt.AudioVbr = false; opt.SampleRateIndex = 2; opt.ChannelsIndex = 2; } - ), - new( - "aac-standard-192", - "표준 192k (모바일·웹)", - $"{ext.ToUpperInvariant()} 192kbps · 44.1kHz 표준 인코딩", - new[] { "192 kbps", "44.1 kHz", "표준" }, - opt => { opt.AudioBitrateKbps = 192; opt.AudioVbr = false; opt.SampleRateIndex = 1; opt.ChannelsIndex = 2; } - ), - new( - "aac-compact-128", - "가벼운 공유 128k", - "128kbps 효율적 압축 전송용", - new[] { "128 kbps", "경량화" }, - opt => { opt.AudioBitrateKbps = 128; opt.ChannelsIndex = 2; } - ) - }; - - private static IReadOnlyList LosslessAudioPresets(string ext) => new List - { - new( - "lossless-master", - "무손실 스튜디오 마스터 (24-bit 96kHz)", - $"{ext.ToUpperInvariant()} 원음 무손실 아카이빙", - new[] { "무손실 100%", "24-bit", "96 kHz" }, - opt => { opt.AudioBitrateKbps = 320; opt.SampleRateIndex = 0; opt.ChannelsIndex = 0; } - ), - new( - "lossless-cd", - "CD 음질 무손실 (16-bit 44.1kHz)", - $"{ext.ToUpperInvariant()} 표준 CD 무손실 추출", - new[] { "무손실", "16-bit", "44.1 kHz" }, - opt => { opt.AudioBitrateKbps = 320; opt.SampleRateIndex = 1; opt.ChannelsIndex = 2; } - ) - }; - - private static IReadOnlyList OggOpusPresets(string ext) => new List - { - new( - "opus-high", - "고음질 보이스/음악 (192k)", - $"{ext.ToUpperInvariant()} 최신 고효율 코덱", - new[] { "192 kbps", "차세대 코덱" }, - opt => { opt.AudioBitrateKbps = 192; } - ), - new( - "opus-voice", - "음성 스트리밍 (96k)", - "디스코드/보이스 최적화", - new[] { "96 kbps", "초저지연" }, - opt => { opt.AudioBitrateKbps = 96; } - ) - }; - - private static IReadOnlyList WebpPresets() => new List - { - new( - "webp-web-q85", - "웹 고화질 (Q85 · 추천)", - "Quality 85 · EXIF 메타데이터 제거 · 웹 게시 표준", - new[] { "Quality 85", "Strip EXIF", "웹 최적화" }, - opt => { opt.ImageQuality = 85; opt.StripMetadata = true; } - ), - new( - "webp-compact-q65", - "웹 초경량 (Q65 · 빠른 로딩)", - "Quality 65 · 고압축 이미지로 첫 페이지 로딩 가속", - new[] { "Quality 65", "Strip EXIF", "초경량" }, - opt => { opt.ImageQuality = 65; opt.StripMetadata = true; } - ), - new( - "webp-lossless", - "무손실 그래픽 (Lossless 100%)", - "100% 무손실 픽셀 보존 · 알파 투명도 완벽 보존", - new[] { "무손실 100%", "투명도 보존", "그래픽·아이콘" }, - opt => { opt.ImageQuality = 100; opt.StripMetadata = false; } - ), - new( - "webp-sns-thumb", - "SNS 썸네일 (Q75)", - "피드 및 카드 썸네일 최적화", - new[] { "Quality 75", "썸네일" }, - opt => { opt.ImageQuality = 75; opt.StripMetadata = true; } - ) - }; - - private static IReadOnlyList AvifPresets() => new List - { - new( - "avif-balanced", - "차세대 초고압축 (Q55)", - "AV1 코덱 기반 압축률 극대화", - new[] { "Quality 55", "AV1 코덱", "초고압축" }, - opt => { opt.ImageQuality = 85; opt.StripMetadata = true; } - ), - new( - "avif-high", - "고화질 아카이빙 (Q75)", - "색상 심도 10-bit HDR 보존", - new[] { "Quality 75", "10-bit HDR" }, - opt => { opt.ImageQuality = 95; opt.StripMetadata = false; } - ) - }; - - private static IReadOnlyList JpgPresets() => new List - { - new( - "jpg-photo-q95", - "디지털 인화·고화질 (Q95)", - "Quality 95 · 색상 프로파일 보존 · 선명한 사진", - new[] { "Quality 95", "ICC 보존", "고해상도" }, - opt => { opt.ImageQuality = 95; opt.StripMetadata = false; } - ), - new( - "jpg-web-q80", - "웹 표준 (Q80 · 권장)", - "Quality 80 · 프로그레시브 JPEG · 메타데이터 제거", - new[] { "Quality 80", "Strip EXIF", "프로그레시브" }, - opt => { opt.ImageQuality = 80; opt.StripMetadata = true; } - ), - new( - "jpg-compact-q70", - "모바일 메신저 (Q70)", - "Quality 70 · 카카오톡/문자 전송 가벼운 용량", - new[] { "Quality 70", "용량 절약" }, - opt => { opt.ImageQuality = 70; opt.StripMetadata = true; } - ) - }; - - private static IReadOnlyList PngPresets() => new List - { - new( - "png-max-compress", - "최대 압축 무손실 (Level 9)", - "PNG Deflate 최대 압축 · 무손실 픽셀 완전 보존", - new[] { "PNG Level 9", "무손실", "디테일 100%" }, - opt => { opt.ImageQuality = 100; opt.StripMetadata = false; } - ), - new( - "png-web-graphic", - "웹 그래픽 최적화", - "불필요한 청크 제거 · 알파 투명도 보존", - new[] { "웹 최적화", "투명도 보존", "EXIF 제거" }, - opt => { opt.ImageQuality = 100; opt.StripMetadata = true; } - ) - }; - - private static IReadOnlyList PdfPresets() => new List - { - new( - "pdf-print-300", - "인쇄용 고해상도 (300 DPI)", - "300 DPI 래스터/벡터 폰트 임베딩 · 인쇄소 출력용", - new[] { "300 DPI", "벡터 폰트", "최고품질" }, - opt => { opt.Quality = 100; } - ), - new( - "pdf-web-150", - "웹 배포·문서 공유 (150 DPI)", - "150 DPI 텍스트 선명도 및 적정 용량 밸런스", - new[] { "150 DPI", "문서 공유", "경량화" }, - opt => { opt.Quality = 85; } - ), - new( - "pdf-ebook-96", - "모바일·전자책 (96 DPI)", - "96 DPI 초경량화 · 빠른 스크롤 및 이메일 첨부", - new[] { "96 DPI", "초경량", "이메일 첨부" }, - opt => { opt.Quality = 70; } - ), - new( - "pdf-bw-scan", - "흑백 스캔 압축 (문서 전용)", - "그레이스케일 고압축 · 텍스트 가독성 강화", - new[] { "그레이스케일", "고압축" }, - opt => { opt.Quality = 60; } - ) - }; - - private static IReadOnlyList Mp4Presets() => new List - { - new( - "mp4-web-1080p", - "웹 스트리밍 1080p (추천)", - "H.264 Fast · CRF 23 · AAC 192k · 웹/브라우저 재생", - new[] { "1080p", "H.264 Fast", "CRF 23", "AAC 192k" }, - opt => { opt.VideoCrf = 23; opt.VideoPreset = "fast"; opt.ResolutionIndex = 3; opt.AudioBitrateKbps = 192; } - ), - new( - "mp4-sns-720p", - "SNS 메신저 720p (10MB 타겟)", - "H.264 VeryFast · CRF 26 · AAC 128k · 모바일 가벼운 공유", - new[] { "720p", "CRF 26", "AAC 128k", "용량 절약" }, - opt => { opt.VideoCrf = 26; opt.VideoPreset = "veryfast"; opt.ResolutionIndex = 4; opt.AudioBitrateKbps = 128; } - ), - new( - "mp4-master-crf18", - "초고화질 마스터 (CRF 18)", - "원본 해상도 유지 · H.264 Medium · 오디오 무손실 복사", - new[] { "원본 해상도", "CRF 18", "Medium", "고화질 보존" }, - opt => { opt.VideoCrf = 18; opt.VideoPreset = "medium"; opt.ResolutionIndex = 0; opt.AudioBitrateKbps = 320; } - ), - new( - "mp4-audio-extract", - "오디오 트랙 추출 (AAC)", - "영상에서 오디오만 고음질로 분리", - new[] { "오디오 전용", "AAC 256k" }, - opt => { opt.AudioBitrateKbps = 256; } - ) - }; - - private static IReadOnlyList GeneralVideoPresets(string ext) => new List - { - new( - "video-standard", - $"{ext.ToUpperInvariant()} 표준 인코딩", - "CRF 23 · Fast 프리셋", - new[] { "CRF 23", "Fast", ext }, - opt => { opt.VideoCrf = 23; opt.VideoPreset = "fast"; } - ), - new( - "video-quality", - $"{ext.ToUpperInvariant()} 고화질 보존", - "CRF 18 · Medium 프리셋", - new[] { "CRF 18", "Medium" }, - opt => { opt.VideoCrf = 18; opt.VideoPreset = "medium"; } - ) - }; - - private static IReadOnlyList DataPresets(string ext) => new List - { - new( - "data-standard", - "표준 UTF-8 인코딩", - $"{ext.ToUpperInvariant()} 표준 서식 정리", - new[] { "UTF-8", "표준 포맷" }, - _ => { } - ), - new( - "data-excel-compat", - "Excel 호환 서식 (BOM 포함)", - "Excel에서 한글 깨짐 없는 UTF-8 BOM", - new[] { "UTF-8 BOM", "엑셀 호환" }, - _ => { } - ) - }; - - private static IReadOnlyList DocumentPresets(string ext) => new List - { - new( - "doc-standard", - "표준 문서 서식", - $"{ext.ToUpperInvariant()} 기본 텍스트/스타일 변환", - new[] { "표준 서식", ext }, - _ => { } - ), - new( - "doc-clean", - "서식 단순화 (경량)", - "군더더기 태그 및 서식 정돈", - new[] { "클린 서식", "경량화" }, - _ => { } - ) - }; - - private static IReadOnlyList FallbackPresets(string ext) => new List - { - new( - "default-standard", - "기본 표준 변환", - $"{ext} 기본 파라미터 적용", - new[] { "기본값", ext }, - opt => { opt.Quality = 85; } - ), - new( - "default-quality", - "품질 우선 변환", - "최고 품질 설정 적용", - new[] { "고품질", "무손실 우선" }, - opt => { opt.Quality = 100; } - ) - }; -} diff --git a/src/Everything2Everything.Tests/DesignAuditAstTests.cs b/src/Everything2Everything.Tests/DesignAuditAstTests.cs index 82a999d..d9a0894 100644 --- a/src/Everything2Everything.Tests/DesignAuditAstTests.cs +++ b/src/Everything2Everything.Tests/DesignAuditAstTests.cs @@ -161,6 +161,7 @@ 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)); @@ -233,15 +234,12 @@ public class DesignAuditAstTests var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml"); var doc = XDocument.Parse(File.ReadAllText(mainFile)); - // 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); + // 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에 선언되어야 합니다."); // 2. SearchBox (검색창 존재) var searchBox = doc.Descendants() @@ -261,164 +259,4 @@ 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); - } } - - diff --git a/src/Everything2Everything.Tests/DesignAuditVisualTreeTests.cs b/src/Everything2Everything.Tests/DesignAuditVisualTreeTests.cs index 7b70bed..db3390f 100644 --- a/src/Everything2Everything.Tests/DesignAuditVisualTreeTests.cs +++ b/src/Everything2Everything.Tests/DesignAuditVisualTreeTests.cs @@ -25,8 +25,6 @@ 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; @@ -34,12 +32,9 @@ public class DesignAuditVisualTreeTests { try { - lock (AppInitLock) + if (Application.Current == null) { - if (Application.Current == null) - { - try { _ = new Application(); } catch (InvalidOperationException) { } - } + _ = new Application(); } action(); } @@ -80,32 +75,6 @@ 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() { @@ -186,69 +155,6 @@ 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(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 FindLogicalChildren(object parent) where T : DependencyObject { if (parent is ContentControl cc && cc.Content != null) diff --git a/src/Everything2Everything.Tests/FormatPresetEngineTests.cs b/src/Everything2Everything.Tests/FormatPresetEngineTests.cs deleted file mode 100644 index 6c3940b..0000000 --- a/src/Everything2Everything.Tests/FormatPresetEngineTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Everything2Everything.App.ViewModels; -using Everything2Everything.Core.Presets; -using Xunit; - -namespace Everything2Everything.Tests; - -/// -/// 출력 형식 연동 스마트 프리셋 엔진 TDD 단위 테스트 (RED -> GREEN) -/// -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("기본")); - } -} diff --git a/src/Everything2Everything.Tests/UnifiedTitleBarTests.cs b/src/Everything2Everything.Tests/UnifiedTitleBarTests.cs deleted file mode 100644 index e195779..0000000 --- a/src/Everything2Everything.Tests/UnifiedTitleBarTests.cs +++ /dev/null @@ -1,437 +0,0 @@ -using System.IO; -using System.Linq; -using System.Threading; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -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 _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(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 이하로 컴팩트하게 구성되어야 왼쪽 탭과 겹치지 않습니다."); - }); - } - - [Fact] - public void SettingsWindow_TitleBar_MustDisplayProperlyAlignedHeaderAndCloseButton() - { - 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)); - - var titleBar = FindLogicalChild(window); - Assert.NotNull(titleBar); - titleBar.ApplyTemplate(); - - // 1. TitleBar height must be 42px - Assert.Equal(42.0, titleBar.ActualHeight); - - // 2. PART_MainGrid must stretch to 42px - var mainGrid = (FrameworkElement)titleBar.Template.FindName("PART_MainGrid", titleBar); - Assert.NotNull(mainGrid); - Assert.Equal(42.0, mainGrid.ActualHeight); - - // 3. Find the rendered TextBlock with text "설정" - var textBlocks = FindVisualChildren(titleBar).Where(t => t.Text == "설정").ToList(); - Assert.NotEmpty(textBlocks); - var titleTb = textBlocks.First(); - - // 4. TextBlock "설정" must not be at X=0 (must have left padding/margin >= 12px) - var textPt = titleTb.TransformToAncestor(titleBar).Transform(new Point(0, 0)); - Assert.True(textPt.X >= 12.0, $"Title text X coordinate ({textPt.X}px) must be >= 12px to prevent touching window edge."); - - var closeBtn = (FrameworkElement)titleBar.Template.FindName("PART_CloseButton", titleBar); - Assert.NotNull(closeBtn); - Assert.Equal(Visibility.Visible, closeBtn.Visibility); - - // 5. Title text center Y and Close button center Y must match within 2.0px tolerance - var textCenterY = textPt.Y + titleTb.ActualHeight / 2.0; - var closeCenterY = closeBtn.TransformToAncestor(titleBar).Transform(new Point(0, closeBtn.ActualHeight / 2.0)).Y; - Assert.True(Math.Abs(textCenterY - closeCenterY) <= 2.0, - $"Title text CenterY ({textCenterY:F1}px) and CloseButton CenterY ({closeCenterY:F1}px) must match within 2px."); - }); - } - - [Fact] - public void DiagnoseWindow_TitleBar_MustHaveStandardHeightAndButtons() - { - RunOnSta(() => - { - var engine = Everything2EverythingBootstrap.CreateDefault(); - var window = new DiagnoseWindow(engine); - - var content = (UIElement)window.Content; - content.Measure(new Size(680, 580)); - content.Arrange(new Rect(0, 0, 680, 580)); - - var titleBar = FindLogicalChild(window); - Assert.NotNull(titleBar); - titleBar.ApplyTemplate(); - - // Height must be standard 42px (not hardcoded 32px) - Assert.Equal(42.0, titleBar.ActualHeight); - Assert.False(titleBar.ShowMaximize); - Assert.False(titleBar.ShowMinimize); - }); - } - - [Fact] - public void QuickOptionsWindow_TitleBar_MustHaveStandardHeightAndButtons() - { - 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)); - - var titleBar = FindLogicalChild(window); - Assert.NotNull(titleBar); - titleBar.ApplyTemplate(); - - Assert.Equal(42.0, titleBar.ActualHeight); - Assert.False(titleBar.ShowMaximize); - Assert.False(titleBar.ShowMinimize); - }); - } - - - private static IEnumerable FindVisualChildren(DependencyObject parent) where T : DependencyObject - { - int count = VisualTreeHelper.GetChildrenCount(parent); - for (int i = 0; i < count; i++) - { - var child = VisualTreeHelper.GetChild(parent, i); - if (child is T typed) yield return typed; - foreach (var descendant in FindVisualChildren(child)) - { - yield return descendant; - } - } - } - - - 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(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(dep); - if (found != null) return found; - } - } - return null; - } -}