diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 3f2b098..fbc347b 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -31,6 +31,7 @@ + diff --git a/src/Everything2Everything.App/Everything2Everything.App.csproj b/src/Everything2Everything.App/Everything2Everything.App.csproj index 7a6002a..21197fe 100644 --- a/src/Everything2Everything.App/Everything2Everything.App.csproj +++ b/src/Everything2Everything.App/Everything2Everything.App.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs b/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs new file mode 100644 index 0000000..9946c33 --- /dev/null +++ b/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs @@ -0,0 +1,55 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Everything2Everything.Core; + +namespace Everything2Everything.App.ViewModels; + +/// +/// 변환 옵션 패널의 상태 + 불변 ConvertOptions 구성 로직(MVVM ViewModel). +/// ToConvertOptions는 순수 함수라 WPF 없이 헤드리스 단위 테스트가 가능하다(P5b: 코드비하인드 BuildOptions 추출). +/// ObservableProperty로 노출되어 향후 옵션 패널 XAML을 이 VM에 직접 바인딩할 수 있다. +/// +public partial class OptionsViewModel : ObservableObject +{ + /// JPEG/WebP 품질(1~100). AVIF는 -30 보정. + [ObservableProperty] private int _quality = 85; + + /// 비우면 원본 옆 서브폴더. 값이 있으면 사용자 지정 출력 폴더. + [ObservableProperty] private string? _customOutputDirectory; + + [ObservableProperty] private NameCollision _conflictRule = NameCollision.AppendNumber; + + /// 0=요약, 1=번역, 2=교정 (AI 텍스트 변환). + [ObservableProperty] private int _aiTaskIndex; + + [ObservableProperty] private string? _targetLanguage; + + [ObservableProperty] private bool _videoPreferGpu = true; + + /// 현재 상태로 불변 ConvertOptions를 구성한다(기존 MainWindow.BuildOptions와 동일 동작). + public ConvertOptions ToConvertOptions() + { + var hasCustom = !string.IsNullOrWhiteSpace(CustomOutputDirectory); + var aiTask = AiTaskIndex switch + { + 1 => "translate", + 2 => "proofread", + _ => "summarize", + }; + + return new ConvertOptions + { + OnCollision = ConflictRule, + OutputLocation = hasCustom ? OutputLocation.Custom : OutputLocation.SubfolderBesideSource, + CustomOutputDirectory = hasCustom ? CustomOutputDirectory!.Trim() : null, + Jpeg = new JpegEncodingOptions { Quality = Quality }, + Webp = new WebpEncodingOptions { Quality = Quality }, + Avif = new AvifEncodingOptions { Quality = Math.Clamp(Quality - 30, 1, 100) }, + Ai = new AiOptions + { + Task = aiTask, + TargetLanguage = string.IsNullOrWhiteSpace(TargetLanguage) ? null : TargetLanguage.Trim(), + }, + VideoPreferGpu = VideoPreferGpu, + }; + } +} diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs index d2ed810..acb047c 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml.cs +++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs @@ -9,6 +9,7 @@ using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; using Everything2Everything.App.Shell; +using Everything2Everything.App.ViewModels; using Everything2Everything.Core; using LossClass = Everything2Everything.Core.Providers.LossClass; @@ -18,6 +19,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow { private readonly ConversionEngine _engine; private readonly ISettingsStore _settings; + private readonly OptionsViewModel _options = new(); private readonly ObservableCollection _activeQueue = new(); private readonly ObservableCollection _pastResults = new(); private CancellationTokenSource? _cts; @@ -278,35 +280,14 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow private ConvertOptions BuildOptions() { - var q = (int)QualitySlider.Value; - - var custom = OutputPathTextBox.Text?.Trim(); - var hasCustom = !string.IsNullOrEmpty(custom); - - // AI 작업 종류 (txt↔txt / md↔md 등 텍스트 변환 시 LlmProvider가 사용) - var aiTask = (AiTaskCombo?.SelectedIndex ?? 0) switch - { - 1 => "translate", - 2 => "proofread", - _ => "summarize", - }; - var targetLang = AiTargetLangBox?.Text?.Trim(); - - return new ConvertOptions - { - OnCollision = _conflictRule, - OutputLocation = hasCustom ? OutputLocation.Custom : OutputLocation.SubfolderBesideSource, - CustomOutputDirectory = hasCustom ? custom : null, - Jpeg = new JpegEncodingOptions { Quality = q }, - Webp = new WebpEncodingOptions { Quality = q }, - Avif = new AvifEncodingOptions { Quality = Math.Clamp(q - 30, 1, 100) }, - Ai = new AiOptions - { - Task = aiTask, - TargetLanguage = string.IsNullOrEmpty(targetLang) ? null : targetLang, - }, - VideoPreferGpu = _settings.Get("video.gpu") != "false", - }; + // 뷰의 컨트롤 값을 OptionsViewModel에 반영한 뒤, 불변 ConvertOptions 구성은 VM의 순수 메서드에 위임한다. + _options.Quality = (int)QualitySlider.Value; + _options.CustomOutputDirectory = OutputPathTextBox.Text?.Trim(); + _options.ConflictRule = _conflictRule; + _options.AiTaskIndex = AiTaskCombo?.SelectedIndex ?? 0; + _options.TargetLanguage = AiTargetLangBox?.Text?.Trim(); + _options.VideoPreferGpu = _settings.Get("video.gpu") != "false"; + return _options.ToConvertOptions(); } // ============== Process queue ============== diff --git a/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj b/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj index 9aadbac..62c33a9 100644 --- a/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj +++ b/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Everything2Everything.Tests/OptionsViewModelTests.cs b/src/Everything2Everything.Tests/OptionsViewModelTests.cs new file mode 100644 index 0000000..01e9ae3 --- /dev/null +++ b/src/Everything2Everything.Tests/OptionsViewModelTests.cs @@ -0,0 +1,68 @@ +using Everything2Everything.App.ViewModels; +using Everything2Everything.Core; +using Xunit; + +namespace Everything2Everything.Tests; + +/// +/// OptionsViewModel.ToConvertOptions 단위 테스트 — 기존 MainWindow.BuildOptions(코드비하인드, 테스트 불가)에서 +/// 추출한 순수 옵션 구성 로직을 헤드리스로 검증(P5b MVVM). UI 컨트롤 읽기와 분리되어 회귀를 기계적으로 방어. +/// +public class OptionsViewModelTests +{ + [Fact] + public void ToConvertOptions_MapsQualityToImageFormats() + { + var o = new OptionsViewModel { Quality = 70 }.ToConvertOptions(); + Assert.Equal(70, o.Jpeg.Quality); + Assert.Equal(70, o.Webp.Quality); + Assert.Equal(40, o.Avif.Quality); // AVIF = Quality - 30 + } + + [Fact] + public void ToConvertOptions_AvifQualityClampedToFloor() + { + Assert.Equal(1, new OptionsViewModel { Quality = 10 }.ToConvertOptions().Avif.Quality); + } + + [Fact] + public void ToConvertOptions_EmptyCustomDir_UsesSubfolder() + { + var o = new OptionsViewModel { CustomOutputDirectory = " " }.ToConvertOptions(); + Assert.Equal(OutputLocation.SubfolderBesideSource, o.OutputLocation); + Assert.Null(o.CustomOutputDirectory); + } + + [Fact] + public void ToConvertOptions_CustomDir_TrimmedAndCustomLocation() + { + var o = new OptionsViewModel { CustomOutputDirectory = " C:\\out " }.ToConvertOptions(); + Assert.Equal(OutputLocation.Custom, o.OutputLocation); + Assert.Equal("C:\\out", o.CustomOutputDirectory); + } + + [Theory] + [InlineData(0, "summarize")] + [InlineData(1, "translate")] + [InlineData(2, "proofread")] + [InlineData(99, "summarize")] + public void ToConvertOptions_MapsAiTask(int index, string expected) + { + Assert.Equal(expected, new OptionsViewModel { AiTaskIndex = index }.ToConvertOptions().Ai.Task); + } + + [Fact] + public void ToConvertOptions_TargetLanguage_EmptyBecomesNullElseTrimmed() + { + Assert.Null(new OptionsViewModel { TargetLanguage = " " }.ToConvertOptions().Ai.TargetLanguage); + Assert.Equal("일본어", new OptionsViewModel { TargetLanguage = " 일본어 " }.ToConvertOptions().Ai.TargetLanguage); + } + + [Fact] + public void ToConvertOptions_PassesConflictAndGpu() + { + var o = new OptionsViewModel { ConflictRule = NameCollision.Skip, VideoPreferGpu = false }.ToConvertOptions(); + Assert.Equal(NameCollision.Skip, o.OnCollision); + Assert.False(o.VideoPreferGpu); + } +}