feat(tdd): implement SSOT canon, AGENTS.md, ultra-strict design audit, E2E scenarios, and Forgejo release pipeline
Some checks are pending
Forgejo Release / build-and-release (push) Waiting to run
Some checks are pending
Forgejo Release / build-and-release (push) Waiting to run
This commit is contained in:
parent
24aa70ae32
commit
4cd9c678c0
25 changed files with 1684 additions and 238 deletions
|
|
@ -97,17 +97,25 @@ public partial class App : Application
|
|||
|
||||
private async Task RunQuickAsync(IReadOnlyList<string> files, string outputExtension)
|
||||
{
|
||||
// 옵션 팝업~변환 경로 전체를 명시적 종료 모드로 먼저 감싼다.
|
||||
// 핵심(크래시 방지): 기본 OnLastWindowClose면 옵션 팝업(첫·유일 창)이 닫히는 순간 "마지막 창 종료" 규칙이
|
||||
// 발동해 앱이 종료 시퀀스에 진입하고, 직후 ShutdownMode 설정/변환이 InvalidOperationException으로 크래시한다.
|
||||
// (또한 진행 창을 닫아도 변환을 취소한 '뒤' 명시 종료해 ffmpeg 고아 프로세스를 막는다.)
|
||||
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
// 빠른 변환 전, 출력 형식에 맞춘 간단 옵션 팝업.
|
||||
// '자세히 옵션…'이면 풀 UI(MainWindow)로 전환, '취소'면 종료, '변환'이면 선택 옵션으로 진행.
|
||||
var optWin = new QuickOptionsWindow(outputExtension, files.Count, Settings);
|
||||
var confirmed = optWin.ShowDialog();
|
||||
if (optWin.OpenFullUi) { ShowConvertDialog(files); return; }
|
||||
if (optWin.OpenFullUi)
|
||||
{
|
||||
// 풀 UI로 전환 — 종료 책임을 메인 창 수명(OnLastWindowClose)에 되돌린다.
|
||||
ShutdownMode = System.Windows.ShutdownMode.OnLastWindowClose;
|
||||
ShowConvertDialog(files);
|
||||
return;
|
||||
}
|
||||
if (confirmed != true) { Shutdown(0); return; }
|
||||
|
||||
// 변환 경로 동안은 명시적 종료 모드 — 진행 창을 닫아도 변환을 취소(ffmpeg 종료)한 '뒤' 앱을 종료한다.
|
||||
// (기본 OnLastWindowClose면 창 닫는 즉시 종료가 시작돼 ffmpeg가 고아로 백그라운드에 남는다.)
|
||||
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
var logPath = Path.Combine(Path.GetTempPath(), "Everything2Everything_quick.log");
|
||||
var log = new System.Text.StringBuilder();
|
||||
log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start → {outputExtension}, {files.Count} file(s)");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>1.0.6</Version>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
|
@ -35,3 +36,4 @@
|
|||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,17 @@
|
|||
<Setter Property="Foreground" Value="{StaticResource FsTextSecondary}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Fluent typography aliases (Used across dialogs) -->
|
||||
<Style x:Key="TextSubtitle" TargetType="TextBlock" BasedOn="{StaticResource FsBodyStyle}">
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
<Style x:Key="TextCaption" TargetType="TextBlock" BasedOn="{StaticResource FsCaptionStyle}"/>
|
||||
<Style x:Key="TextBody" TargetType="TextBlock" BasedOn="{StaticResource FsBodyStyle}"/>
|
||||
<Style x:Key="TextBodyStrong" TargetType="TextBlock" BasedOn="{StaticResource FsBodyStyle}">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
|
||||
<!-- Slider style: 2px track, 14px round thumb, blue fill up to value -->
|
||||
<Style x:Key="FsSliderStyle" TargetType="Slider">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@
|
|||
|
||||
<!-- AI Task -->
|
||||
<StackPanel x:Name="AiTaskPanel" Margin="0,0,0,32">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Image Width="15" Height="15" VerticalAlignment="Center" Margin="0,0,6,0"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Source="pack://application:,,,/Assets/glyph-ai.png"/>
|
||||
|
|
@ -504,16 +504,17 @@
|
|||
Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="1" CornerRadius="20" Padding="4">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ToggleButton x:Name="TabActiveBtn"
|
||||
Style="{StaticResource FsTabPillStyle}"
|
||||
Command="{Binding TabCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="Active">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Active Queue" VerticalAlignment="Center"/>
|
||||
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}">
|
||||
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="TabActiveBadge" Text="0"
|
||||
FontSize="10" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource FsTextTertiary}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
|
@ -523,11 +524,12 @@
|
|||
Style="{StaticResource FsTabPillStyle}"
|
||||
Command="{Binding TabCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="Past">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Past Results" VerticalAlignment="Center"/>
|
||||
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}">
|
||||
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="TabPastBadge" Text="0"
|
||||
FontSize="10" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource FsTextTertiary}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
|
@ -536,22 +538,47 @@
|
|||
</Border>
|
||||
|
||||
<!-- Actions -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button Content="⚙ 설정" Margin="0,0,8,0"
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding SettingsCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Button Content="Register Menu" Margin="0,0,8,0"
|
||||
Command="{Binding SettingsCommand, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Settings24" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="설정" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding RegisterCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Button Content="Diagnose" Margin="0,0,8,0"
|
||||
Command="{Binding RegisterCommand, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="AppsAddIn24" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="우클릭 메뉴 등록" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding DiagnoseCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Button Content="Export Log" Margin="0,0,8,0"
|
||||
Command="{Binding DiagnoseCommand, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Stethoscope24" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="진단" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding ExportLogCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Button x:Name="ClearAllButton" Content="Clear All"
|
||||
Command="{Binding ExportLogCommand, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="ArrowDownload24" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="로그 내보내기" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="ClearAllButton" Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding ClearAllCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
Command="{Binding ClearAllCommand, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Delete24" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="목록 비우기" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
|
@ -700,9 +727,35 @@
|
|||
</Grid>
|
||||
|
||||
<!-- Past Results view (default) -->
|
||||
<ScrollViewer x:Name="PastResultsView" VerticalScrollBarVisibility="Auto"
|
||||
Padding="24">
|
||||
<ItemsControl x:Name="PastResultsList"
|
||||
<Grid x:Name="PastResultsContainer">
|
||||
<!-- 빈 상태 안내 (Empty State) -->
|
||||
<Grid x:Name="PastResultsEmpty" Visibility="Collapsed">
|
||||
<Border Background="{StaticResource FsBgBase}" Padding="32">
|
||||
<Grid MaxWidth="440" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Rectangle RadiusX="10" RadiusY="10"
|
||||
Stroke="{StaticResource FsBorderHairline}"
|
||||
StrokeThickness="1.25"
|
||||
StrokeDashArray="5 4"
|
||||
Fill="#04FFFFFF"/>
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Margin="48,52">
|
||||
<ui:SymbolIcon Symbol="History24" FontSize="48"
|
||||
Foreground="{StaticResource FsTextTertiary}"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Margin="0,16,0,0" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextPrimary}"
|
||||
HorizontalAlignment="Center"
|
||||
Text="변환 이력이 없습니다"/>
|
||||
<TextBlock Margin="0,6,0,0" Style="{StaticResource FsCaptionStyle}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
Text="파일을 변환하면 완료된 내역과 절감된 용량이 여기에 기록됩니다"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
<ScrollViewer x:Name="PastResultsView" VerticalScrollBarVisibility="Auto"
|
||||
Padding="24">
|
||||
<ItemsControl x:Name="PastResultsList"
|
||||
ItemsSource="{Binding PastResults, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
|
|
@ -813,6 +866,7 @@
|
|||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
<!-- /좌측 컬럼 끝 -->
|
||||
|
|
|
|||
|
|
@ -171,7 +171,8 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
TabPastBtn.IsChecked = tag == "Past";
|
||||
|
||||
ActiveQueueView.Visibility = tag == "Active" ? Visibility.Visible : Visibility.Collapsed;
|
||||
PastResultsView.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed;
|
||||
PastResultsContainer.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed;
|
||||
UpdatePastResultsVisibility();
|
||||
}
|
||||
|
||||
// ============== Drag & Drop ==============
|
||||
|
|
@ -247,11 +248,19 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
ActiveQueueScroll.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void UpdatePastResultsVisibility()
|
||||
{
|
||||
var hasItems = _pastResults.Count > 0;
|
||||
PastResultsEmpty.Visibility = hasItems ? Visibility.Collapsed : Visibility.Visible;
|
||||
PastResultsView.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void UpdateBadges()
|
||||
{
|
||||
TabActiveBadge.Text = _activeQueue.Count.ToString(CultureInfo.InvariantCulture);
|
||||
var count = _pastResults.Sum(g => g.Entries.Count);
|
||||
TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture);
|
||||
UpdatePastResultsVisibility();
|
||||
}
|
||||
|
||||
private void UpdateProcessQueueButton()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@
|
|||
WindowStartupLocation="CenterScreen"
|
||||
ResizeMode="NoResize"
|
||||
Closing="OnWindowClosing">
|
||||
<Window.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Window.Resources>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,17 @@
|
|||
WindowStartupLocation="CenterOwner"
|
||||
ExtendsContentIntoTitleBar="True"
|
||||
WindowBackdropType="Mica"
|
||||
Background="{StaticResource FsBgBase}">
|
||||
Background="{DynamicResource FsBgBase}">
|
||||
|
||||
<Grid>
|
||||
<Window.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Background="{StaticResource FsBgBase}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
|
|
@ -51,10 +59,10 @@
|
|||
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
|
||||
IsEnabled="False" Click="OnVerifyOpenAi"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
|
||||
<Ellipse x:Name="OpenAiDot" Style="{StaticResource FsLossDotStyle}"
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0"/>
|
||||
<TextBlock x:Name="OpenAiStatus" Text="키 미설정" Style="{StaticResource FsCaptionStyle}"/>
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="OpenAiStatus" Text="키 미설정" Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Anthropic -->
|
||||
|
|
@ -68,23 +76,23 @@
|
|||
Style="{StaticResource FsPasswordInputStyle}"
|
||||
PasswordChanged="OnAnthropicKeyChanged"/>
|
||||
<Button x:Name="AnthropicVerifyBtn" Grid.Column="1" Content="확인"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
|
||||
IsEnabled="False" Click="OnVerifyAnthropic"/>
|
||||
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
|
||||
IsEnabled="False" Click="OnVerifyAnthropic"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
|
||||
<Ellipse x:Name="AnthropicDot" Style="{StaticResource FsLossDotStyle}"
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0"/>
|
||||
<TextBlock x:Name="AnthropicStatus" Text="키 미설정" Style="{StaticResource FsCaptionStyle}"/>
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="AnthropicStatus" Text="키 미설정" Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Codex CLI (OAuth) -->
|
||||
<TextBlock Text="Codex CLI (OAuth · API 키 불필요)" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
|
||||
<Ellipse x:Name="CodexDot" Style="{StaticResource FsLossDotStyle}"
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0"/>
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="CodexStatus" Text="확인 중…" Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
|
||||
<Button x:Name="CodexVerifyBtn" Content="테스트" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Margin="12,0,0,0" Padding="10,4" IsEnabled="False" Click="OnVerifyCodex"/>
|
||||
Margin="12,0,0,0" Padding="10,4" IsEnabled="False" Click="OnVerifyCodex" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 모델 -->
|
||||
|
|
@ -113,10 +121,10 @@
|
|||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Ellipse x:Name="FfmpegDot" Style="{StaticResource FsLossDotStyle}"
|
||||
Fill="{StaticResource FsStatusWarn}" Margin="0,0,7,0"/>
|
||||
<TextBlock Text="FFmpeg" Style="{StaticResource FsBodyStyle}" FontWeight="Medium"/>
|
||||
Fill="{StaticResource FsStatusWarn}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="FFmpeg" Style="{StaticResource FsBodyStyle}" FontWeight="Medium" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="FfmpegStatus" Text="미설치" Style="{StaticResource FsCaptionStyle}" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="영상/오디오 변환 (mp4·webm·mp3·flac…)" Style="{StaticResource FsCaptionStyle}" Margin="15,3,0,0"/>
|
||||
|
|
@ -134,10 +142,10 @@
|
|||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Ellipse x:Name="LibreDot" Style="{StaticResource FsLossDotStyle}"
|
||||
Fill="{StaticResource FsStatusWarn}" Margin="0,0,7,0"/>
|
||||
<TextBlock Text="LibreOffice" Style="{StaticResource FsBodyStyle}" FontWeight="Medium"/>
|
||||
Fill="{StaticResource FsStatusWarn}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="LibreOffice" Style="{StaticResource FsBodyStyle}" FontWeight="Medium" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="LibreStatus" Text="미설치" Style="{StaticResource FsCaptionStyle}" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="한글/Word/문서 변환 (HWP·DOCX→PDF/이미지). 한글은 H2Orestart 확장 필요." Style="{StaticResource FsCaptionStyle}" Margin="15,3,0,0" TextWrapping="Wrap"/>
|
||||
|
|
@ -155,9 +163,9 @@
|
|||
<!-- ===== 푸터 ===== -->
|
||||
<Border Grid.Row="2" Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}" BorderThickness="0,1,0,0" Padding="24,14">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="닫기" Style="{StaticResource FsSecondaryButtonStyle}" Padding="18,8" Click="OnClose"/>
|
||||
<Button Content="저장" Style="{StaticResource FsPrimaryButtonStyle}" Padding="22,8" Margin="8,0,0,0" Click="OnSave"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<Button Content="닫기" Style="{StaticResource FsSecondaryButtonStyle}" Padding="20,8" Click="OnClose"/>
|
||||
<Button Content="저장" Style="{StaticResource FsPrimaryButtonStyle}" Padding="20,8" Margin="8,0,0,0" Click="OnSave"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@ public sealed record ConvertOptions
|
|||
/// <summary>독립(Independent) 배치 변환의 최대 병렬 수. 기본 = 논리 코어 수. 미디어(FFmpeg) 위주 배치는 낮춰 오버서브스크립션 회피.</summary>
|
||||
public int BatchParallelism { get; init; } = Environment.ProcessorCount;
|
||||
|
||||
/// <summary>
|
||||
/// LibreOffice(soffice) 변환 1건의 타임아웃(초). 초과 시 프로세스 트리를 강제 종료해 hang을 회수한다.
|
||||
/// 특정 HWP/문서에서 soffice가 무한 대기하는 사례를 방지(기본 120초). 큰 문서가 많으면 늘린다.
|
||||
/// </summary>
|
||||
public int LibreOfficeTimeoutSeconds { get; init; } = 120;
|
||||
|
||||
/// <summary>영상 인코딩 시 GPU 하드웨어 가속(NVENC)을 우선 시도하고, 실패하면 CPU로 자동 폴백한다.</summary>
|
||||
public bool VideoPreferGpu { get; init; } = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Everything2Everything.Core.Providers;
|
||||
using Markdig;
|
||||
|
|
@ -241,37 +240,10 @@ public sealed class DocumentProvider : IConverterProvider
|
|||
throw new InvalidOperationException("LibreOffice를 찾을 수 없습니다.");
|
||||
|
||||
var outDir = Path.GetDirectoryName(Path.GetFullPath(targetPath))!;
|
||||
Directory.CreateDirectory(outDir);
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = soffice,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--headless");
|
||||
psi.ArgumentList.Add("--norestore");
|
||||
psi.ArgumentList.Add("--nofirststartwizard");
|
||||
psi.ArgumentList.Add("--convert-to");
|
||||
psi.ArgumentList.Add(outputFormat);
|
||||
psi.ArgumentList.Add("--outdir");
|
||||
psi.ArgumentList.Add(outDir);
|
||||
psi.ArgumentList.Add(sourcePath);
|
||||
|
||||
using var proc = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("LibreOffice 프로세스 시작 실패");
|
||||
|
||||
try { await proc.WaitForExitAsync(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { try { proc.Kill(true); } catch { } throw; }
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
throw new InvalidOperationException($"LibreOffice 변환 실패 (exit {proc.ExitCode})");
|
||||
|
||||
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + "." + outputFormat);
|
||||
if (!File.Exists(produced))
|
||||
throw new FileNotFoundException("LibreOffice가 결과물을 생성하지 않았습니다.", produced);
|
||||
// soffice 호출 직렬화(기본 프로필 락 충돌 방지) + 타임아웃/트리 kill + 출력 검증은 LibreOfficeRunner가 담당.
|
||||
var produced = await LibreOfficeRunner.ConvertAsync(
|
||||
soffice, sourcePath, outDir, outputFormat, LibreOfficeRunner.DefaultTimeoutSeconds, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!string.Equals(produced, targetPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System.Diagnostics;
|
||||
using Everything2Everything.Core.Providers;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
|
@ -68,19 +67,28 @@ public sealed class HwpxProvider : IConverterProvider
|
|||
return ConvertResult.Fail(sourcePath, "LibreOffice가 필요합니다.");
|
||||
|
||||
var outExt = ConversionPair.Normalize(outputExtension);
|
||||
var tempPdf = Path.Combine(Path.GetTempPath(),
|
||||
$"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
|
||||
// 변환마다 고유 작업폴더 — soffice 출력 파일명(입력 베이스명)이 다른 변환과 outdir에서 충돌하지 않게.
|
||||
var workDir = Path.Combine(Path.GetTempPath(), $"e2e_hwp_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
try
|
||||
{
|
||||
progress?.Report(0.05);
|
||||
|
||||
var converted = await ConvertWithLibreOfficeAsync(soffice, sourcePath, tempPdf, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!converted)
|
||||
string producedPdf;
|
||||
try
|
||||
{
|
||||
// soffice 호출 직렬화 + 타임아웃/프로세스 트리 kill + 출력 검증은 LibreOfficeRunner가 담당한다.
|
||||
producedPdf = await LibreOfficeRunner.ConvertAsync(
|
||||
soffice, sourcePath, workDir, "pdf", options.LibreOfficeTimeoutSeconds, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ConvertResult.Fail(sourcePath,
|
||||
"LibreOffice 변환에 실패했습니다. H2Orestart 확장이 정상 설치되어 있는지 확인하세요.");
|
||||
"LibreOffice 변환에 실패했습니다. H2Orestart 확장과 Java(JRE)가 정상 설치되어 있는지 확인하세요. " + ex.Message, ex);
|
||||
}
|
||||
|
||||
progress?.Report(0.55);
|
||||
|
||||
|
|
@ -90,64 +98,18 @@ public sealed class HwpxProvider : IConverterProvider
|
|||
var finalPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, ".pdf", options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(finalPath, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
File.Copy(tempPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
|
||||
File.Copy(producedPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { finalPath });
|
||||
}
|
||||
|
||||
var inner = new Progress<double>(p => progress?.Report(0.55 + p * 0.45));
|
||||
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, outExt, options, inner, cancellationToken)
|
||||
return _pdfProvider.ConvertCore(producedPdf, outputDirectory, outExt, options, inner, cancellationToken)
|
||||
with { SourcePath = sourcePath };
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempPdf)) File.Delete(tempPdf); } catch { }
|
||||
try { Directory.Delete(workDir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> ConvertWithLibreOfficeAsync(string sofficePath, string sourcePath, string targetPdf, CancellationToken ct)
|
||||
{
|
||||
var outDir = Path.GetDirectoryName(targetPdf)!;
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = sofficePath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--headless");
|
||||
psi.ArgumentList.Add("--norestore");
|
||||
psi.ArgumentList.Add("--nofirststartwizard");
|
||||
psi.ArgumentList.Add("--convert-to");
|
||||
psi.ArgumentList.Add("pdf");
|
||||
psi.ArgumentList.Add("--outdir");
|
||||
psi.ArgumentList.Add(outDir);
|
||||
psi.ArgumentList.Add(sourcePath);
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(true); } catch { }
|
||||
throw;
|
||||
}
|
||||
|
||||
if (proc.ExitCode != 0) return false;
|
||||
|
||||
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + ".pdf");
|
||||
if (!File.Exists(produced)) return false;
|
||||
|
||||
if (!string.Equals(produced, targetPdf, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (File.Exists(targetPdf)) File.Delete(targetPdf);
|
||||
File.Move(produced, targetPdf);
|
||||
}
|
||||
return File.Exists(targetPdf);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
src/Everything2Everything.Core/Converters/LibreOfficeRunner.cs
Normal file
119
src/Everything2Everything.Core/Converters/LibreOfficeRunner.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// soffice(LibreOffice) <c>--headless --convert-to</c> 호출을 단일 지점으로 집약한다.
|
||||
/// HWP/HWPX 등 모든 LibreOffice 경유 변환(HwpxProvider·DocumentProvider)이 이 헬퍼를 통과한다.
|
||||
///
|
||||
/// 2026 리서치·적대적 검증으로 확정된 두 결함을 한 곳에서 방어한다:
|
||||
/// <list type="number">
|
||||
/// <item><b>직렬화 게이트</b> — LibreOffice는 프로필당 단일 인스턴스 설계(~.lock)다. 기본 프로필을 공유한 채
|
||||
/// 여러 soffice를 동시에 spawn하면 둘째 이후 프로세스가 첫 인스턴스에 위임되어 조용히 실패/멈춘다
|
||||
/// (freedesktop Bug 106134/82775). 정적 <see cref="SemaphoreSlim"/>으로 soffice 호출을 직렬화해 락 충돌을 0으로 만든다.
|
||||
/// (이미지 등 다른 Provider의 병렬성은 이 경로를 통과하지 않으므로 영향받지 않는다.)</item>
|
||||
/// <item><b>타임아웃 + 프로세스 트리 kill</b> — 특정 문서에서 soffice가 무한 hang하는 사례가 다수 보고된다.
|
||||
/// 타임아웃 초과 시 <see cref="Process.Kill(bool)"/>로 자식까지 종료해 배치 전체가 멈추는 사고를 회수한다.</item>
|
||||
/// </list>
|
||||
/// 성공은 종료코드뿐 아니라 <b>출력 파일 존재</b>로 검증한다(조용한 스킵이 잘못된 결과로 둔갑하지 않게).
|
||||
/// </summary>
|
||||
internal static class LibreOfficeRunner
|
||||
{
|
||||
// 기본 프로필 락 충돌 방지: soffice 호출을 프로세스 전역에서 직렬화한다.
|
||||
private static readonly SemaphoreSlim Gate = new(1, 1);
|
||||
|
||||
public const int DefaultTimeoutSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="sourcePath"/>를 <paramref name="outputFormat"/>(예: "pdf", "html", "docx", "txt")으로
|
||||
/// 변환해 <paramref name="outDir"/>에 쓰고, 생성된 결과 파일의 전체 경로를 반환한다.
|
||||
/// 결과 파일명은 LibreOffice 규칙상 <c>{입력 베이스명}.{outputFormat}</c>이다. 실패 시 예외를 던진다.
|
||||
/// 출력 파일명 충돌을 피하려면 호출측이 변환마다 고유한 <paramref name="outDir"/>를 넘길 것.
|
||||
/// </summary>
|
||||
public static async Task<string> ConvertAsync(
|
||||
string sofficePath,
|
||||
string sourcePath,
|
||||
string outDir,
|
||||
string outputFormat,
|
||||
int timeoutSeconds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(outDir);
|
||||
var seconds = timeoutSeconds <= 0 ? DefaultTimeoutSeconds : timeoutSeconds;
|
||||
|
||||
await Gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = sofficePath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--headless");
|
||||
psi.ArgumentList.Add("--norestore");
|
||||
psi.ArgumentList.Add("--nofirststartwizard");
|
||||
psi.ArgumentList.Add("--convert-to");
|
||||
psi.ArgumentList.Add(outputFormat);
|
||||
psi.ArgumentList.Add("--outdir");
|
||||
psi.ArgumentList.Add(outDir);
|
||||
psi.ArgumentList.Add(sourcePath);
|
||||
|
||||
using var proc = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("LibreOffice 프로세스를 시작하지 못했습니다.");
|
||||
|
||||
// 파이프 버퍼가 가득 차 soffice가 블록되는 것을 막기 위해 두 스트림을 비동기로 비운다.
|
||||
// (프로세스가 종료/강제종료되면 스트림 EOF로 두 태스크 모두 완료된다.)
|
||||
var stdErrTask = proc.StandardError.ReadToEndAsync();
|
||||
var stdOutTask = proc.StandardOutput.ReadToEndAsync();
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(seconds));
|
||||
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); } catch { /* 이미 종료됨 */ }
|
||||
if (ct.IsCancellationRequested)
|
||||
throw; // 사용자 취소 — 위로 전파
|
||||
throw new TimeoutException(
|
||||
$"LibreOffice 변환이 {seconds}초를 초과해 중단했습니다: {Path.GetFileName(sourcePath)}");
|
||||
}
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
var err = await SafeReadAsync(stdErrTask).ConfigureAwait(false);
|
||||
var detail = string.IsNullOrWhiteSpace(err) ? "" : " " + err.Trim();
|
||||
throw new InvalidOperationException(
|
||||
$"LibreOffice 변환 실패 (exit {proc.ExitCode}).{detail}");
|
||||
}
|
||||
|
||||
// 출력 스트림은 정상 경로에서 굳이 쓰지 않지만, 버퍼가 비워지도록 마저 완료시킨다.
|
||||
_ = await SafeReadAsync(stdOutTask).ConfigureAwait(false);
|
||||
|
||||
var produced = Path.Combine(outDir,
|
||||
Path.GetFileNameWithoutExtension(sourcePath) + "." + outputFormat);
|
||||
if (!File.Exists(produced))
|
||||
throw new FileNotFoundException(
|
||||
"LibreOffice가 결과물을 생성하지 않았습니다. 한글 입력이면 H2Orestart 확장과 Java(JRE) 설치를 확인하세요.",
|
||||
produced);
|
||||
|
||||
return produced;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> SafeReadAsync(Task<string> readTask)
|
||||
{
|
||||
try { return await readTask.ConfigureAwait(false); }
|
||||
catch { return ""; }
|
||||
}
|
||||
}
|
||||
230
src/Everything2Everything.Tests/DesignAuditAstTests.cs
Normal file
230
src/Everything2Everything.Tests/DesignAuditAstTests.cs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 초엄격 디자인 & UX 감사 TDD 테스트 스위트 (XAML 정적 AST 분석)
|
||||
/// UI에 시각적 치우침, 폰트 미가독, 컨테이너 오버플로, 인풋 패딩 오류, 아이콘-텍스트 수직 불일치, 이모지 혼용이 없는지 정밀 검증한다.
|
||||
/// </summary>
|
||||
public class DesignAuditAstTests
|
||||
{
|
||||
private static readonly string SolutionRoot = FindSolutionRoot();
|
||||
private static readonly string ViewsDir = Path.Combine(SolutionRoot, "src", "Everything2Everything.App", "Views");
|
||||
|
||||
private static string FindSolutionRoot()
|
||||
{
|
||||
var dir = Directory.GetCurrentDirectory();
|
||||
while (dir != null && !File.Exists(Path.Combine(dir, "Everything2Everything.slnx")))
|
||||
{
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
return dir ?? throw new DirectoryNotFoundException("솔루션 루트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetXamlFiles()
|
||||
{
|
||||
return Directory.GetFiles(ViewsDir, "*.xaml", SearchOption.AllDirectories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllXamlFiles_ExistAndAreWellFormedXml()
|
||||
{
|
||||
var files = GetXamlFiles().ToList();
|
||||
Assert.NotEmpty(files);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
var doc = XDocument.Parse(content);
|
||||
Assert.NotNull(doc.Root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ButtonContent_MustNotContainRawUnicodeEmojis()
|
||||
{
|
||||
// ⚙, 📁, 🗑, ✖, 🔧 등의 유니코드 이모지가 Button Content 속성에 날것으로 들어있으면 안 된다.
|
||||
// Fluent SymbolIcon이나 분리된 벡터 Path를 사용해야 베이스라인이 깨지지 않는다.
|
||||
var rawEmojis = new[] { "⚙", "📁", "🗑", "✖", "🔧", "✨", "🚀", "⚡" };
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var file in GetXamlFiles())
|
||||
{
|
||||
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||
var buttons = doc.Descendants().Where(e => e.Name.LocalName == "Button" || e.Name.LocalName == "ToggleButton");
|
||||
|
||||
foreach (var btn in buttons)
|
||||
{
|
||||
var content = btn.Attribute("Content")?.Value;
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
foreach (var emoji in rawEmojis)
|
||||
{
|
||||
if (content.Contains(emoji))
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
violations.Add($"[{fileName}] 버튼 Content에 날것의 이모지 '{emoji}' 발견: \"{content}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
$"유니코드 이모지 혼용 버튼이 발견되었습니다 (Fluent SymbolIcon 또는 분리된 Path로 수정 필요):\n" +
|
||||
string.Join("\n", violations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalStackPanels_WithIconAndText_MustHaveVerticalAlignmentCenter()
|
||||
{
|
||||
// Horizontal StackPanel 안에 Icon/Image/Path/Symbol/Ellipse 와 TextBlock 이 함께 들어갈 때,
|
||||
// 부모 StackPanel 또는 자식 요소들에 VerticalAlignment="Center" 가 누락되면 아이콘과 텍스트가 위아래로 어긋난다.
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var file in GetXamlFiles())
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||
var stackPanels = doc.Descendants().Where(e => e.Name.LocalName == "StackPanel" &&
|
||||
e.Attribute("Orientation")?.Value == "Horizontal");
|
||||
|
||||
foreach (var sp in stackPanels)
|
||||
{
|
||||
var children = sp.Elements().ToList();
|
||||
bool hasIcon = children.Any(c => c.Name.LocalName is "Image" or "Path" or "SymbolIcon" or "Ellipse" or "Border");
|
||||
bool hasText = children.Any(c => c.Name.LocalName == "TextBlock");
|
||||
|
||||
if (hasIcon && hasText)
|
||||
{
|
||||
string? parentVAlign = sp.Attribute("VerticalAlignment")?.Value;
|
||||
bool allChildrenCentered = children.All(c => c.Attribute("VerticalAlignment")?.Value == "Center");
|
||||
|
||||
if (parentVAlign != "Center" && !allChildrenCentered)
|
||||
{
|
||||
var lineInfo = (System.Xml.IXmlLineInfo)sp;
|
||||
violations.Add($"[{fileName}:L{lineInfo.LineNumber}] 아이콘과 텍스트가 함께 들어있는 가로 StackPanel에 VerticalAlignment=\"Center\" 누락");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
$"아이콘-텍스트 수직 정렬 불일치가 발견되었습니다 (VerticalAlignment=\"Center\" 필수):\n" +
|
||||
string.Join("\n", violations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputBoxStyles_MustHaveAdequatePadding()
|
||||
{
|
||||
// TextBox, PasswordBox 스타일은 텍스트 글리프가 테두리에 닿지 않도록
|
||||
// 수평 최소 8px, 수직 최소 4px 이상의 패딩을 가져야 한다.
|
||||
var themeFile = Path.Combine(ViewsDir, "FormatShiftTheme.xaml");
|
||||
var doc = XDocument.Parse(File.ReadAllText(themeFile));
|
||||
|
||||
var inputStyles = doc.Descendants()
|
||||
.Where(e => e.Name.LocalName == "Style" &&
|
||||
e.Attribute("TargetType")?.Value is "TextBox" or "PasswordBox")
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(inputStyles);
|
||||
|
||||
foreach (var style in inputStyles)
|
||||
{
|
||||
var styleKey = style.Attribute(XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value;
|
||||
var paddingSetter = style.Elements()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "Setter" && e.Attribute("Property")?.Value == "Padding");
|
||||
|
||||
Assert.NotNull(paddingSetter);
|
||||
var paddingVal = paddingSetter.Attribute("Value")?.Value;
|
||||
Assert.NotNull(paddingVal);
|
||||
|
||||
var parts = paddingVal.Split(',').Select(p => double.Parse(p.Trim())).ToArray();
|
||||
double hPad = parts[0];
|
||||
double vPad = parts.Length > 1 ? parts[1] : parts[0];
|
||||
|
||||
Assert.True(hPad >= 8, $"스타일 [{styleKey}]의 수평 패딩({hPad})이 최소 규격(8px) 미만입니다.");
|
||||
Assert.True(vPad >= 4, $"스타일 [{styleKey}]의 수직 패딩({vPad})이 최소 규격(4px) 미만입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopNavigationButtons_MustHaveConsistentKoreanLabels()
|
||||
{
|
||||
// MainWindow 상단 네비게이션 액션 버튼들은 한국어로 일관성 있게 통일되어야 한다.
|
||||
// ("⚙ 설정", "Register Menu", "Diagnose", "Export Log", "Clear All" 같은 영한 혼용 방지)
|
||||
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||
|
||||
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));
|
||||
|
||||
Assert.NotNull(actionsPanel);
|
||||
|
||||
var buttons = actionsPanel.Elements().Where(e => e.Name.LocalName == "Button").ToList();
|
||||
Assert.True(buttons.Count >= 4, "상단 액션 버튼이 최소 4개 이상이어야 합니다.");
|
||||
|
||||
var englishKeywords = new[] { "Register Menu", "Diagnose", "Export Log", "Clear All" };
|
||||
var rawMixedButtons = new List<string>();
|
||||
|
||||
foreach (var btn in buttons)
|
||||
{
|
||||
var content = btn.Attribute("Content")?.Value ?? "";
|
||||
if (englishKeywords.Any(k => content.Equals(k, StringComparison.OrdinalIgnoreCase)) || content.Contains("⚙"))
|
||||
{
|
||||
rawMixedButtons.Add(content);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(rawMixedButtons.Count == 0,
|
||||
$"상단 네비게이션 버튼에 혼용 또는 영문 레이블이 발견되었습니다 (정제된 한국어 표준으로 통일 필요):\n" +
|
||||
string.Join(", ", rawMixedButtons));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PastResultsView_MustHaveEmptyStateIndicator()
|
||||
{
|
||||
// PastResultsView(변환 이력) 화면이 비어 있을 때 아무것도 안 나오는 시커먼 공백(Zero-void)이 되지 않도록
|
||||
// Empty State 안내 컨테이너(PastEmpty 또는 동등한 플레이스홀더)가 존재해야 한다.
|
||||
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||
|
||||
var pastResultsView = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PastResultsView");
|
||||
|
||||
Assert.NotNull(pastResultsView);
|
||||
|
||||
// 부모 Grid 또는 PastResultsView 내부에 Empty State를 위한 요소가 존재하는지 검사
|
||||
var emptyState = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value is "PastResultsEmpty" or "PastEmpty");
|
||||
|
||||
Assert.NotNull(emptyState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DialogFooterButtons_MustHaveConsistentPadding()
|
||||
{
|
||||
// SettingsWindow 및 QuickOptionsWindow의 확인/취소 푸터 버튼은 동일 위계이므로
|
||||
// 일치하는 패딩 규격을 가져야 한다 (예: 닫기 18,8 vs 저장 22,8 비대칭 금지).
|
||||
var settingsFile = Path.Combine(ViewsDir, "SettingsWindow.xaml");
|
||||
var doc = XDocument.Parse(File.ReadAllText(settingsFile));
|
||||
|
||||
var footer = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "Border" && e.Attribute("Grid.Row")?.Value == "2");
|
||||
|
||||
Assert.NotNull(footer);
|
||||
|
||||
var buttons = footer.Descendants().Where(e => e.Name.LocalName == "Button").ToList();
|
||||
Assert.True(buttons.Count >= 2);
|
||||
|
||||
var paddings = buttons.Select(b => b.Attribute("Padding")?.Value).Distinct().ToList();
|
||||
Assert.True(paddings.Count == 1,
|
||||
$"SettingsWindow 푸터 버튼들의 패딩이 서로 다릅니다 (동일 위계 버튼은 패딩 통일 필수): {string.Join(", ", paddings)}");
|
||||
}
|
||||
}
|
||||
153
src/Everything2Everything.Tests/DesignAuditVisualTreeTests.cs
Normal file
153
src/Everything2Everything.Tests/DesignAuditVisualTreeTests.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 초엄격 Visual & Layout In-Memory 배치 TDD 테스트 스위트
|
||||
/// STA 스레드에서 창과 컨트롤을 가상 렌더링(Measure & Arrange)하여
|
||||
/// NaN/Infinity 크기 오류, 요소 클리핑/오버플로, 터치/클릭 최소 타깃 규격을 자동 검증한다.
|
||||
/// </summary>
|
||||
public class DesignAuditVisualTreeTests
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
private static void RunOnSta(Action action)
|
||||
{
|
||||
Exception? ex = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Application.Current == null)
|
||||
{
|
||||
_ = new Application();
|
||||
}
|
||||
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 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<ButtonBase>(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<ButtonBase>(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);
|
||||
});
|
||||
}
|
||||
|
||||
private static IEnumerable<T> FindLogicalChildren<T>(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<T>(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<T>(rawChild)) yield return grandChild;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/Everything2Everything.Tests/E2EUserScenarioTests.cs
Normal file
100
src/Everything2Everything.Tests/E2EUserScenarioTests.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Everything2Everything.App.ViewModels;
|
||||
using Everything2Everything.Core;
|
||||
using Everything2Everything.Core.Providers;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 복합 사용자 유즈케이스 E2E 시나리오 TDD 테스트 스위트
|
||||
/// 실제 사용자의 큐 조작, 포맷 교집합 필터링, 인코딩 옵션 튜닝, 진행률 클램프 및 취소 시나리오를 검증한다.
|
||||
/// </summary>
|
||||
public class E2EUserScenarioTests
|
||||
{
|
||||
private readonly ConversionEngine _engine;
|
||||
|
||||
public E2EUserScenarioTests()
|
||||
{
|
||||
_engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scenario1_FileQueue_ComputesCommonFormatIntersection_AndSortsCorrectly()
|
||||
{
|
||||
// 사용자가 .png 이미지와 .docx 문서를 함께 드래그 & 드롭했을 때,
|
||||
// 사이드바의 출력 포맷 콤보박스는 두 입력이 "동시에 변환 가능한 출력의 교집합"만 남겨야 한다.
|
||||
var pngOutputs = _engine.Providers.OutputsForInput(".png").ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var docxOutputs = _engine.Providers.OutputsForInput(".docx").ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 교집합 계산
|
||||
var intersection = pngOutputs.Intersect(docxOutputs, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
Assert.NotEmpty(intersection);
|
||||
// PDF, JPG 등은 PNG와 DOCX 양쪽 모두에서 공통 출력 가능해야 함
|
||||
Assert.Contains(".pdf", intersection);
|
||||
Assert.Contains(".jpg", intersection);
|
||||
|
||||
// 만약 여기에 다른 포맷(.csv 등)이 추가되면 교집합이 변하거나 비어있어야 함
|
||||
var csvOutputs = _engine.Providers.OutputsForInput(".csv").ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var threeWayIntersection = intersection.Intersect(csvOutputs, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
// CSV는 텍스트/표 기반이므로 이미지/PDF와의 교집합은 없어야 함
|
||||
Assert.DoesNotContain(".jpg", threeWayIntersection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scenario2_OptionsViewModel_Tuning_ReflectsInEncoderParameters()
|
||||
{
|
||||
// 사용자가 슬라이더와 콤보박스로 인코딩 옵션을 조정하는 시나리오
|
||||
var vm = new OptionsViewModel
|
||||
{
|
||||
Quality = 92,
|
||||
Crf = 18,
|
||||
ResolutionIndex = 3, // 1080p
|
||||
AudioBitrateIndex = 4, // 320 kbps
|
||||
VideoPreferGpu = false
|
||||
};
|
||||
|
||||
Assert.Equal(92, vm.Quality);
|
||||
Assert.Equal(18, vm.Crf);
|
||||
Assert.Equal(3, vm.ResolutionIndex);
|
||||
Assert.Equal(4, vm.AudioBitrateIndex);
|
||||
Assert.False(vm.VideoPreferGpu);
|
||||
|
||||
// ConvertOptions 에 매핑되는 로직 검증
|
||||
var options = vm.ToConvertOptions();
|
||||
|
||||
Assert.Equal(92, options.Jpeg.Quality);
|
||||
Assert.Equal(92, options.Webp.Quality);
|
||||
Assert.Equal(18, options.Video.Crf);
|
||||
Assert.False(options.VideoPreferGpu);
|
||||
Assert.Equal(1920, options.Video.ScaleWidth);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-10.0, 0.0)]
|
||||
[InlineData(0.0, 0.0)]
|
||||
[InlineData(45.5, 45.5)]
|
||||
[InlineData(100.0, 100.0)]
|
||||
[InlineData(125.0, 100.0)]
|
||||
public void Scenario3_ProgressReporting_ClampsValuesCleanlyBetween0And100(double rawValue, double expectedClamped)
|
||||
{
|
||||
// 백그라운드 인코더에서 비정상적인 퍼센트(-5% 또는 105%)가 전달되더라도
|
||||
// UI 프로그레스 바가 깨지지 않도록 [0.0, 100.0] 범위로 정밀 클램프되어야 한다.
|
||||
double clamped = Math.Clamp(rawValue, 0.0, 100.0);
|
||||
Assert.Equal(expectedClamped, clamped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scenario4_ConversionGraph_FindBestPath_ProvidesValidSteps()
|
||||
{
|
||||
// 다단계 변환 그래프 탐색 유즈케이스 검증
|
||||
var path = _engine.Providers.Graph.FindBestPath(".png", ".jpg");
|
||||
Assert.NotNull(path);
|
||||
Assert.NotEmpty(path);
|
||||
Assert.Equal(".png", path![0].From);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue