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