refactor(P5b-1): CommunityToolkit.Mvvm 도입 + OptionsViewModel 추출 (옵션 로직 테스트화)
MVVM 첫 슬라이스 — 테스트 불가능했던 코드비하인드 BuildOptions의 순수 옵션 구성 로직을 헤드리스 단위테스트 가능한 OptionsViewModel.ToConvertOptions로 추출. XAML 바인딩 미변경(런타임 위험 0). - CommunityToolkit.Mvvm 8.2.2(CPM) + App.csproj. App/ViewModels/OptionsViewModel.cs: ObservableObject + [ObservableProperty](Quality/CustomOutputDirectory/ConflictRule/AiTaskIndex/ TargetLanguage/VideoPreferGpu) → 향후 옵션 패널 직접 바인딩 토대. ToConvertOptions()는 순수. - MainWindow.BuildOptions: 컨트롤 값을 VM에 반영 후 ToConvertOptions() 위임(동작 동일). - Tests → App ProjectReference 추가(App 뷰모델 헤드리스 테스트 가능화). - OptionsViewModelTests 10케이스(품질매핑·AVIF clamp·커스텀폴더 trim·AI작업·대상언어·충돌/GPU). 71 → 81개 테스트 전부 그린, 빌드 0경고/0오류.
This commit is contained in:
parent
729b9b699d
commit
ba5738e705
6 changed files with 136 additions and 29 deletions
|
|
@ -31,6 +31,7 @@
|
||||||
|
|
||||||
<!-- App -->
|
<!-- App -->
|
||||||
<PackageVersion Include="WPF-UI" Version="4.3.0" />
|
<PackageVersion Include="WPF-UI" Version="4.3.0" />
|
||||||
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.2.2" />
|
||||||
|
|
||||||
<!-- Tests -->
|
<!-- Tests -->
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="WPF-UI" />
|
<PackageReference Include="WPF-UI" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
55
src/Everything2Everything.App/ViewModels/OptionsViewModel.cs
Normal file
55
src/Everything2Everything.App/ViewModels/OptionsViewModel.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Everything2Everything.Core;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 변환 옵션 패널의 상태 + 불변 ConvertOptions 구성 로직(MVVM ViewModel).
|
||||||
|
/// ToConvertOptions는 순수 함수라 WPF 없이 헤드리스 단위 테스트가 가능하다(P5b: 코드비하인드 BuildOptions 추출).
|
||||||
|
/// ObservableProperty로 노출되어 향후 옵션 패널 XAML을 이 VM에 직접 바인딩할 수 있다.
|
||||||
|
/// </summary>
|
||||||
|
public partial class OptionsViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
/// <summary>JPEG/WebP 품질(1~100). AVIF는 -30 보정.</summary>
|
||||||
|
[ObservableProperty] private int _quality = 85;
|
||||||
|
|
||||||
|
/// <summary>비우면 원본 옆 서브폴더. 값이 있으면 사용자 지정 출력 폴더.</summary>
|
||||||
|
[ObservableProperty] private string? _customOutputDirectory;
|
||||||
|
|
||||||
|
[ObservableProperty] private NameCollision _conflictRule = NameCollision.AppendNumber;
|
||||||
|
|
||||||
|
/// <summary>0=요약, 1=번역, 2=교정 (AI 텍스트 변환).</summary>
|
||||||
|
[ObservableProperty] private int _aiTaskIndex;
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _targetLanguage;
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _videoPreferGpu = true;
|
||||||
|
|
||||||
|
/// <summary>현재 상태로 불변 ConvertOptions를 구성한다(기존 MainWindow.BuildOptions와 동일 동작).</summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ using System.Windows.Input;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
using Everything2Everything.App.Shell;
|
using Everything2Everything.App.Shell;
|
||||||
|
using Everything2Everything.App.ViewModels;
|
||||||
using Everything2Everything.Core;
|
using Everything2Everything.Core;
|
||||||
using LossClass = Everything2Everything.Core.Providers.LossClass;
|
using LossClass = Everything2Everything.Core.Providers.LossClass;
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
{
|
{
|
||||||
private readonly ConversionEngine _engine;
|
private readonly ConversionEngine _engine;
|
||||||
private readonly ISettingsStore _settings;
|
private readonly ISettingsStore _settings;
|
||||||
|
private readonly OptionsViewModel _options = new();
|
||||||
private readonly ObservableCollection<QueueItem> _activeQueue = new();
|
private readonly ObservableCollection<QueueItem> _activeQueue = new();
|
||||||
private readonly ObservableCollection<DateGroup> _pastResults = new();
|
private readonly ObservableCollection<DateGroup> _pastResults = new();
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
|
|
@ -278,35 +280,14 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
|
|
||||||
private ConvertOptions BuildOptions()
|
private ConvertOptions BuildOptions()
|
||||||
{
|
{
|
||||||
var q = (int)QualitySlider.Value;
|
// 뷰의 컨트롤 값을 OptionsViewModel에 반영한 뒤, 불변 ConvertOptions 구성은 VM의 순수 메서드에 위임한다.
|
||||||
|
_options.Quality = (int)QualitySlider.Value;
|
||||||
var custom = OutputPathTextBox.Text?.Trim();
|
_options.CustomOutputDirectory = OutputPathTextBox.Text?.Trim();
|
||||||
var hasCustom = !string.IsNullOrEmpty(custom);
|
_options.ConflictRule = _conflictRule;
|
||||||
|
_options.AiTaskIndex = AiTaskCombo?.SelectedIndex ?? 0;
|
||||||
// AI 작업 종류 (txt↔txt / md↔md 등 텍스트 변환 시 LlmProvider가 사용)
|
_options.TargetLanguage = AiTargetLangBox?.Text?.Trim();
|
||||||
var aiTask = (AiTaskCombo?.SelectedIndex ?? 0) switch
|
_options.VideoPreferGpu = _settings.Get("video.gpu") != "false";
|
||||||
{
|
return _options.ToConvertOptions();
|
||||||
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",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== Process queue ==============
|
// ============== Process queue ==============
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Everything2Everything.Core\Everything2Everything.Core.csproj" />
|
<ProjectReference Include="..\Everything2Everything.Core\Everything2Everything.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\Everything2Everything.App\Everything2Everything.App.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
68
src/Everything2Everything.Tests/OptionsViewModelTests.cs
Normal file
68
src/Everything2Everything.Tests/OptionsViewModelTests.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
using Everything2Everything.App.ViewModels;
|
||||||
|
using Everything2Everything.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OptionsViewModel.ToConvertOptions 단위 테스트 — 기존 MainWindow.BuildOptions(코드비하인드, 테스트 불가)에서
|
||||||
|
/// 추출한 순수 옵션 구성 로직을 헤드리스로 검증(P5b MVVM). UI 컨트롤 읽기와 분리되어 회귀를 기계적으로 방어.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue