Compare commits
6 commits
455c8b1324
...
c607dff47d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c607dff47d | ||
|
|
d6b7ec2274 | ||
|
|
6a41c4a0dc | ||
|
|
f00f6567f4 | ||
|
|
388d6707d0 | ||
|
|
4ca7352dc3 |
61 changed files with 5489 additions and 865 deletions
43
.github/workflows/build.yml
vendored
43
.github/workflows/build.yml
vendored
|
|
@ -1,43 +0,0 @@
|
||||||
name: Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master, main]
|
|
||||||
pull_request:
|
|
||||||
branches: [master, main]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: windows-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: 9.0.x
|
|
||||||
|
|
||||||
- name: Setup MSBuild
|
|
||||||
uses: microsoft/setup-msbuild@v2
|
|
||||||
|
|
||||||
- name: Restore + Build solution
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
dotnet build Everything2Everything.slnx -c Release --nologo
|
|
||||||
|
|
||||||
- name: Build MSIX
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
./packaging/GenerateAssets.ps1
|
|
||||||
./packaging/BuildMsix.ps1 -Configuration Release -Platform x64
|
|
||||||
|
|
||||||
- name: Upload MSIX artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Everything2Everything-x64-msix-${{ github.sha }}
|
|
||||||
path: packaging/dist/Everything2Everything-x64.msix
|
|
||||||
retention-days: 14
|
|
||||||
98
.github/workflows/release.yml
vendored
98
.github/workflows/release.yml
vendored
|
|
@ -1,98 +0,0 @@
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: '릴리즈 태그 (예: v0.1.0)'
|
|
||||||
required: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: windows-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: 9.0.x
|
|
||||||
|
|
||||||
- name: Setup MSBuild
|
|
||||||
uses: microsoft/setup-msbuild@v2
|
|
||||||
|
|
||||||
- name: Generate placeholder logos
|
|
||||||
shell: pwsh
|
|
||||||
run: ./packaging/GenerateAssets.ps1
|
|
||||||
|
|
||||||
- name: Decode PFX (if secret provided)
|
|
||||||
id: pfx
|
|
||||||
shell: pwsh
|
|
||||||
env:
|
|
||||||
PFX_BASE64: ${{ secrets.PFX_BASE64 }}
|
|
||||||
run: |
|
|
||||||
if ($env:PFX_BASE64) {
|
|
||||||
$bytes = [Convert]::FromBase64String($env:PFX_BASE64)
|
|
||||||
$path = Join-Path $env:RUNNER_TEMP 'cert.pfx'
|
|
||||||
[IO.File]::WriteAllBytes($path, $bytes)
|
|
||||||
"pfx_path=$path" >> $env:GITHUB_OUTPUT
|
|
||||||
Write-Host '✅ PFX 디코딩 완료 — 서명 모드로 빌드'
|
|
||||||
} else {
|
|
||||||
Write-Host 'ℹ PFX_BASE64 secret 없음 — unsigned 모드로 빌드'
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Build MSIX (signed if PFX, else unsigned)
|
|
||||||
shell: pwsh
|
|
||||||
env:
|
|
||||||
PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
$pfx = '${{ steps.pfx.outputs.pfx_path }}'
|
|
||||||
if ($pfx) {
|
|
||||||
$sec = ConvertTo-SecureString -String $env:PFX_PASSWORD -AsPlainText -Force
|
|
||||||
./packaging/BuildMsix.ps1 -Configuration Release -Platform x64 -Sign -PfxPath $pfx -PfxPassword $sec
|
|
||||||
} else {
|
|
||||||
./packaging/BuildMsix.ps1 -Configuration Release -Platform x64
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Everything2Everything-x64-msix
|
|
||||||
path: packaging/dist/Everything2Everything-x64.msix
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
|
|
||||||
files: packaging/dist/Everything2Everything-x64.msix
|
|
||||||
generate_release_notes: true
|
|
||||||
body: |
|
|
||||||
## Everything2Everything
|
|
||||||
|
|
||||||
모든 파일을 우클릭 한 번으로 JPEG 로 변환합니다.
|
|
||||||
|
|
||||||
### 설치 방법
|
|
||||||
1. 아래 `Everything2Everything-x64.msix` 다운로드.
|
|
||||||
2. 관리자 PowerShell:
|
|
||||||
```powershell
|
|
||||||
# 자체 서명 인증서를 신뢰 저장소에 등록 (PFX 별도 보유 필요)
|
|
||||||
Import-PfxCertificate -CertStoreLocation Cert:\LocalMachine\TrustedPeople `
|
|
||||||
-FilePath .\Everything2Everything-DevCert.pfx `
|
|
||||||
-Password (ConvertTo-SecureString 'Everything2EverythingDev' -AsPlainText -Force)
|
|
||||||
|
|
||||||
# MSIX 사이드로드
|
|
||||||
Add-AppxPackage -Path .\Everything2Everything-x64.msix -ForceApplicationShutdown
|
|
||||||
```
|
|
||||||
3. PNG/HEIC/PDF 등 파일 우클릭 → "JPEG로 빠른 변환" 또는 "JPEG로 변환…"
|
|
||||||
|
|
||||||
> 이 패키지는 자체 서명입니다. 첫 PC 1회만 인증서 등록이 필요합니다.
|
|
||||||
|
|
||||||
### 변경사항
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -47,6 +47,9 @@ src/Everything2Everything.Shell/Everythi*/
|
||||||
# .NET artifacts dir from BuildMsix.ps1
|
# .NET artifacts dir from BuildMsix.ps1
|
||||||
artifacts/
|
artifacts/
|
||||||
|
|
||||||
|
# test assets
|
||||||
|
test_assets/
|
||||||
|
|
||||||
# NuGet packages restore + nuget.exe cache
|
# NuGet packages restore + nuget.exe cache
|
||||||
packages/
|
packages/
|
||||||
tools/*
|
tools/*
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com">
|
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com">
|
||||||
<Identity Name="Everything2Everything.YunChan" Publisher="CN=Everything2EverythingDev" Version="1.0.16.0" ProcessorArchitecture="x64" />
|
<Identity Name="Everything2Everything.YunChan" Publisher="CN=Everything2EverythingDev" Version="1.0.21.0" ProcessorArchitecture="x64" />
|
||||||
<Properties>
|
<Properties>
|
||||||
<DisplayName>Everything2Everything</DisplayName>
|
<DisplayName>Everything2Everything</DisplayName>
|
||||||
<PublisherDisplayName>YunChan</PublisherDisplayName>
|
<PublisherDisplayName>YunChan</PublisherDisplayName>
|
||||||
|
|
|
||||||
|
|
@ -76,15 +76,27 @@ public partial class App : Application
|
||||||
|
|
||||||
private void ShowMainWindow()
|
private void ShowMainWindow()
|
||||||
{
|
{
|
||||||
var window = new MainWindow(Engine, Settings);
|
RunFirstRunSetupIfNeeded();
|
||||||
|
var adService = _services.GetService<Everything2Everything.Core.Ads.IAdService>();
|
||||||
|
var window = new MainWindow(Engine, Settings, null, adService);
|
||||||
MainWindow = window;
|
MainWindow = window;
|
||||||
window.Show();
|
window.Show();
|
||||||
window.Activate();
|
window.Activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>첫 실행이면 외부 도구 설치 마법사를 한 번 띄우고 완료 플래그를 남긴다(설정 창에서 재진입 가능).</summary>
|
||||||
|
private void RunFirstRunSetupIfNeeded()
|
||||||
|
{
|
||||||
|
if (Settings.Get("setup.tools.done") == "1") return;
|
||||||
|
var wizard = new ToolSetupWindow();
|
||||||
|
wizard.ShowDialog();
|
||||||
|
Settings.Set("setup.tools.done", "1");
|
||||||
|
}
|
||||||
|
|
||||||
private void ShowConvertDialog(IReadOnlyList<string> files)
|
private void ShowConvertDialog(IReadOnlyList<string> files)
|
||||||
{
|
{
|
||||||
var window = new Views.MainWindow(Engine, Settings, files);
|
var adService = _services.GetService<Everything2Everything.Core.Ads.IAdService>();
|
||||||
|
var window = new Views.MainWindow(Engine, Settings, files, adService);
|
||||||
MainWindow = window;
|
MainWindow = window;
|
||||||
window.Show();
|
window.Show();
|
||||||
window.Activate();
|
window.Activate();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.0.11</Version>
|
<Version>1.0.18</Version>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
|
||||||
106
src/Everything2Everything.App/ViewModels/AdViewModel.cs
Normal file
106
src/Everything2Everything.App/ViewModels/AdViewModel.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using Everything2Everything.Core.Ads;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.ViewModels;
|
||||||
|
|
||||||
|
public class AdViewModel : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private readonly IAdService _adService;
|
||||||
|
private AdItem? _currentBannerAd;
|
||||||
|
private AdItem? _currentLargeAd;
|
||||||
|
private bool _isBannerVisible = true;
|
||||||
|
private bool _isLargeCardVisible = true;
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
public AdItem? CurrentBannerAd
|
||||||
|
{
|
||||||
|
get => _currentBannerAd;
|
||||||
|
set { _currentBannerAd = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdItem? CurrentLargeAd
|
||||||
|
{
|
||||||
|
get => _currentLargeAd;
|
||||||
|
set { _currentLargeAd = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBannerVisible
|
||||||
|
{
|
||||||
|
get => _isBannerVisible;
|
||||||
|
set { _isBannerVisible = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLargeCardVisible
|
||||||
|
{
|
||||||
|
get => _isLargeCardVisible;
|
||||||
|
set { _isLargeCardVisible = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand ClickBannerAdCommand { get; }
|
||||||
|
public ICommand ClickLargeAdCommand { get; }
|
||||||
|
public ICommand DismissBannerCommand { get; }
|
||||||
|
public ICommand DismissLargeCardCommand { get; }
|
||||||
|
|
||||||
|
public AdViewModel(IAdService adService)
|
||||||
|
{
|
||||||
|
_adService = adService ?? throw new ArgumentNullException(nameof(adService));
|
||||||
|
|
||||||
|
CurrentBannerAd = _adService.GetNextBannerAd();
|
||||||
|
CurrentLargeAd = _adService.GetNextLargeCardAd();
|
||||||
|
|
||||||
|
ClickBannerAdCommand = new AdCommand(_ =>
|
||||||
|
{
|
||||||
|
if (CurrentBannerAd != null)
|
||||||
|
_adService.OpenAdUrl(CurrentBannerAd);
|
||||||
|
});
|
||||||
|
|
||||||
|
ClickLargeAdCommand = new AdCommand(_ =>
|
||||||
|
{
|
||||||
|
if (CurrentLargeAd != null)
|
||||||
|
_adService.OpenAdUrl(CurrentLargeAd);
|
||||||
|
});
|
||||||
|
|
||||||
|
DismissBannerCommand = new AdCommand(_ => IsBannerVisible = false);
|
||||||
|
DismissLargeCardCommand = new AdCommand(_ => IsLargeCardVisible = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RotateBannerAd()
|
||||||
|
{
|
||||||
|
CurrentBannerAd = _adService.GetNextBannerAd();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RotateLargeCardAd()
|
||||||
|
{
|
||||||
|
CurrentLargeAd = _adService.GetNextLargeCardAd();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AdCommand : ICommand
|
||||||
|
{
|
||||||
|
private readonly Action<object?> _execute;
|
||||||
|
private readonly Func<object?, bool>? _canExecute;
|
||||||
|
|
||||||
|
public AdCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
|
||||||
|
{
|
||||||
|
_execute = execute;
|
||||||
|
_canExecute = canExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
||||||
|
public void Execute(object? parameter) => _execute(parameter);
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,13 @@ public partial class OptionsViewModel : ObservableObject
|
||||||
[ObservableProperty] private int _channelsIndex; // 0=원본,1=모노,2=스테레오
|
[ObservableProperty] private int _channelsIndex; // 0=원본,1=모노,2=스테레오
|
||||||
[ObservableProperty] private bool _loudnorm;
|
[ObservableProperty] private bool _loudnorm;
|
||||||
|
|
||||||
|
// ── 상세 인코딩 옵션 (폴드아웃 Expander 상태 및 전문 규격) ────────────────────────────────
|
||||||
|
[ObservableProperty] private bool _isAdvancedExpanded = true; // 사용자 피드백: 슬라이더/옵션을 바로 볼 수 있도록 기본 전개
|
||||||
|
[ObservableProperty] private int _pdfCompressLevelIndex; // 0=Light, 1=Strong, 2=Max
|
||||||
|
[ObservableProperty] private int _pdfDpiIndex = 1; // 0=150, 1=200, 2=300
|
||||||
|
[ObservableProperty] private bool _imageLossless; // WebP/PNG 무손실
|
||||||
|
[ObservableProperty] private bool _progressive; // JPEG 프로그레시브 웹 로딩
|
||||||
|
|
||||||
/// <summary>현재 상태로 불변 ConvertOptions를 구성한다(기존 MainWindow.BuildOptions와 동일 동작 + 영상/오디오).</summary>
|
/// <summary>현재 상태로 불변 ConvertOptions를 구성한다(기존 MainWindow.BuildOptions와 동일 동작 + 영상/오디오).</summary>
|
||||||
public ConvertOptions ToConvertOptions()
|
public ConvertOptions ToConvertOptions()
|
||||||
{
|
{
|
||||||
|
|
@ -77,15 +84,23 @@ public partial class OptionsViewModel : ObservableObject
|
||||||
_ => "summarize",
|
_ => "summarize",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var dpi = PdfDpiIndex switch
|
||||||
|
{
|
||||||
|
0 => 150,
|
||||||
|
2 => 300,
|
||||||
|
_ => 200,
|
||||||
|
};
|
||||||
|
|
||||||
return new ConvertOptions
|
return new ConvertOptions
|
||||||
{
|
{
|
||||||
OnCollision = ConflictRule,
|
OnCollision = ConflictRule,
|
||||||
OutputLocation = hasCustom ? OutputLocation.Custom : OutputLocation.SubfolderBesideSource,
|
OutputLocation = hasCustom ? OutputLocation.Custom : OutputLocation.SubfolderBesideSource,
|
||||||
CustomOutputDirectory = hasCustom ? CustomOutputDirectory!.Trim() : null,
|
CustomOutputDirectory = hasCustom ? CustomOutputDirectory!.Trim() : null,
|
||||||
KeepExifWhenPossible = !StripMetadata,
|
KeepExifWhenPossible = !StripMetadata,
|
||||||
Jpeg = new JpegEncodingOptions { Quality = Quality },
|
Jpeg = new JpegEncodingOptions { Quality = Quality, Progressive = Progressive },
|
||||||
Webp = new WebpEncodingOptions { Quality = Quality },
|
Webp = new WebpEncodingOptions { Quality = Quality, Lossless = ImageLossless },
|
||||||
Avif = new AvifEncodingOptions { Quality = Math.Clamp(Quality - 30, 1, 100) },
|
Avif = new AvifEncodingOptions { Quality = Math.Clamp(Quality - 30, 1, 100) },
|
||||||
|
PdfRender = new PdfRenderOptions { Dpi = dpi },
|
||||||
Ai = new AiOptions
|
Ai = new AiOptions
|
||||||
{
|
{
|
||||||
Task = aiTask,
|
Task = aiTask,
|
||||||
|
|
|
||||||
185
src/Everything2Everything.App/ViewModels/ToolSetupViewModel.cs
Normal file
185
src/Everything2Everything.App/ViewModels/ToolSetupViewModel.cs
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.ViewModels;
|
||||||
|
|
||||||
|
public enum ToolInstallState
|
||||||
|
{
|
||||||
|
NotInstalled,
|
||||||
|
Installed,
|
||||||
|
Busy,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>설치 마법사 목록의 한 행. 카탈로그 정의 + 선택/상태/진행 표현.</summary>
|
||||||
|
public sealed class ToolInstallItem : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public ExternalToolDefinition Definition { get; }
|
||||||
|
public ToolInstallItem(ExternalToolDefinition definition)
|
||||||
|
{
|
||||||
|
Definition = definition;
|
||||||
|
RefreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => Definition.DisplayName;
|
||||||
|
public string Description => Definition.Description;
|
||||||
|
|
||||||
|
private bool _isSelected = true;
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => _isSelected;
|
||||||
|
set { _isSelected = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToolInstallState _state;
|
||||||
|
public ToolInstallState State
|
||||||
|
{
|
||||||
|
get => _state;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
_state = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(IsBusy));
|
||||||
|
OnPropertyChanged(nameof(StatusText));
|
||||||
|
OnPropertyChanged(nameof(StatusBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string StatusText => State switch
|
||||||
|
{
|
||||||
|
ToolInstallState.Installed => "설치됨",
|
||||||
|
ToolInstallState.Busy => "설치 중…",
|
||||||
|
ToolInstallState.Failed => "실패",
|
||||||
|
_ => "미설치",
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool IsBusy => State == ToolInstallState.Busy;
|
||||||
|
|
||||||
|
public Brush StatusBrush => State switch
|
||||||
|
{
|
||||||
|
ToolInstallState.Installed => BrushOf("FsStatusSuccess"),
|
||||||
|
ToolInstallState.Busy => BrushOf("FsStatusInfo"),
|
||||||
|
ToolInstallState.Failed => BrushOf("FsStatusDanger"),
|
||||||
|
_ => BrushOf("FsStatusWarn"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private string? _detail;
|
||||||
|
public string? Detail
|
||||||
|
{
|
||||||
|
get => _detail;
|
||||||
|
private set { _detail = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshStatus()
|
||||||
|
=> State = Definition.IsInstalled() ? ToolInstallState.Installed : ToolInstallState.NotInstalled;
|
||||||
|
|
||||||
|
public void ApplyResult(bool success, string? detail)
|
||||||
|
{
|
||||||
|
Detail = detail;
|
||||||
|
State = success ? ToolInstallState.Installed : ToolInstallState.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginInstall() => State = ToolInstallState.Busy;
|
||||||
|
|
||||||
|
private static Brush BrushOf(string key)
|
||||||
|
=> (Application.Current?.TryFindResource(key) as Brush)
|
||||||
|
?? _fallbacks.GetValueOrDefault(key, _warn);
|
||||||
|
|
||||||
|
private static readonly Brush _info = Fixed(0x1565C0);
|
||||||
|
private static readonly Brush _success = Fixed(0x2E7D32);
|
||||||
|
private static readonly Brush _warn = Fixed(0xC77700);
|
||||||
|
private static readonly Brush _danger = Fixed(0xC62828);
|
||||||
|
private static readonly Dictionary<string, Brush> _fallbacks = new()
|
||||||
|
{
|
||||||
|
["FsStatusInfo"] = _info,
|
||||||
|
["FsStatusSuccess"] = _success,
|
||||||
|
["FsStatusWarn"] = _warn,
|
||||||
|
["FsStatusDanger"] = _danger,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Brush Fixed(uint rgb)
|
||||||
|
{
|
||||||
|
var c = (int)rgb;
|
||||||
|
return new SolidColorBrush(Color.FromRgb((byte)(c >> 16), (byte)(c >> 8), (byte)c));
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>설치 마법사의 루트 뷰모델: 목록 + 전체 선택 + 선택분 순차 설치.</summary>
|
||||||
|
public sealed class ToolSetupViewModel : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private readonly ExternalToolInstaller _installer;
|
||||||
|
private bool _isInstalling;
|
||||||
|
private bool _selectAll = true;
|
||||||
|
|
||||||
|
public ObservableCollection<ToolInstallItem> Tools { get; } = new();
|
||||||
|
|
||||||
|
public ToolSetupViewModel(IEnumerable<ExternalToolDefinition>? definitions = null, ExternalToolInstaller? installer = null)
|
||||||
|
{
|
||||||
|
_installer = installer ?? new ExternalToolInstaller();
|
||||||
|
foreach (var def in definitions ?? ExternalToolCatalog.All)
|
||||||
|
Tools.Add(new ToolInstallItem(def));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsInstalling
|
||||||
|
{
|
||||||
|
get => _isInstalling;
|
||||||
|
private set { _isInstalling = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool SelectAll
|
||||||
|
{
|
||||||
|
get => _selectAll;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_selectAll = value;
|
||||||
|
foreach (var t in Tools) t.IsSelected = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshStatus()
|
||||||
|
{
|
||||||
|
foreach (var t in Tools) t.RefreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InstallSelectedAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (IsInstalling) return;
|
||||||
|
IsInstalling = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 이미 설치된 도구는 재설치하지 않고 건너뛴다(감지 상태가 Installed인 항목 제외).
|
||||||
|
foreach (var item in Tools.Where(t => t.IsSelected && t.State != ToolInstallState.Installed).ToList())
|
||||||
|
await InstallOneAsync(item, ct).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsInstalling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InstallOneAsync(ToolInstallItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
item.BeginInstall();
|
||||||
|
var result = await _installer.InstallAsync(item.Definition, ct).ConfigureAwait(true);
|
||||||
|
var detail = string.IsNullOrWhiteSpace(result.Output)
|
||||||
|
? result.Message
|
||||||
|
: Tail(result.Output, 600);
|
||||||
|
item.ApplyResult(result.Success, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Tail(string s, int max)
|
||||||
|
=> s.Length <= max ? s : "…" + s[^max..];
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
99
src/Everything2Everything.App/Views/AdBannerControl.xaml
Normal file
99
src/Everything2Everything.App/Views/AdBannerControl.xaml
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<UserControl x:Class="Everything2Everything.App.Views.AdBannerControl"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignHeight="56" d:DesignWidth="800">
|
||||||
|
<UserControl.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Border Visibility="{Binding IsBannerVisible, Converter={StaticResource BoolToVis}}"
|
||||||
|
Style="{StaticResource FsDoubleBezelShellStyle}"
|
||||||
|
Margin="0,4,0,0">
|
||||||
|
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="12,8">
|
||||||
|
<Grid VerticalAlignment="Center">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- 1. [AD] / [스폰서] 배지 -->
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Margin="0,0,10,0"
|
||||||
|
Padding="6,2"
|
||||||
|
CornerRadius="4"
|
||||||
|
Background="#1A00E5FF"
|
||||||
|
BorderBrush="{StaticResource FsAccentCyan}"
|
||||||
|
BorderThickness="1"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CurrentBannerAd.BadgeText, FallbackValue=AD}"
|
||||||
|
FontSize="10"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource FsAccentCyan}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 2. 광고 타이틀 및 설명 -->
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="{Binding CurrentBannerAd.Title, FallbackValue=스폰서십 안내}"
|
||||||
|
Style="{StaticResource FsLabelStyle}"
|
||||||
|
Foreground="{StaticResource FsTextPrimary}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding CurrentBannerAd.Description, FallbackValue=Everything2Everything을 후원하고 최신 기능을 가장 먼저 만나보세요.}"
|
||||||
|
Style="{StaticResource FsCaptionStyle}"
|
||||||
|
Foreground="{StaticResource FsTextSecondary}"
|
||||||
|
Margin="0,2,0,0"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 3. CTA 액션 버튼 -->
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
Padding="12,4"
|
||||||
|
Height="28"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||||
|
Command="{Binding ClickBannerAdCommand}"
|
||||||
|
ToolTip="광고 및 안내 링크 열기">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CurrentBannerAd.CtaText, FallbackValue='자세히 보기'}"
|
||||||
|
FontSize="11"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<ui:SymbolIcon Symbol="Open24"
|
||||||
|
FontSize="11"
|
||||||
|
Margin="4,0,0,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- 4. 광고 닫기 버튼 -->
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Width="24" Height="24"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Style="{StaticResource FsIconButtonStyle}"
|
||||||
|
Command="{Binding DismissBannerCommand}"
|
||||||
|
ToolTip="광고 닫기">
|
||||||
|
<ui:SymbolIcon Symbol="Dismiss24"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{StaticResource FsTextTertiary}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
11
src/Everything2Everything.App/Views/AdBannerControl.xaml.cs
Normal file
11
src/Everything2Everything.App/Views/AdBannerControl.xaml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.Views;
|
||||||
|
|
||||||
|
public partial class AdBannerControl : UserControl
|
||||||
|
{
|
||||||
|
public AdBannerControl()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/Everything2Everything.App/Views/AdLargeCardControl.xaml
Normal file
117
src/Everything2Everything.App/Views/AdLargeCardControl.xaml
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<UserControl x:Class="Everything2Everything.App.Views.AdLargeCardControl"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignHeight="220" d:DesignWidth="320">
|
||||||
|
<UserControl.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Border Visibility="{Binding IsLargeCardVisible, Converter={StaticResource BoolToVis}}"
|
||||||
|
Style="{StaticResource FsDoubleBezelShellStyle}"
|
||||||
|
Margin="0,16,0,0">
|
||||||
|
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="16">
|
||||||
|
<StackPanel>
|
||||||
|
<!-- 1. 헤더: 스폰서 배지 + 닫기 버튼 -->
|
||||||
|
<Grid Margin="0,0,0,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Border Padding="6,2"
|
||||||
|
CornerRadius="4"
|
||||||
|
Background="#1A3B82F6"
|
||||||
|
BorderBrush="{StaticResource FsAccentBlue}"
|
||||||
|
BorderThickness="1"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CurrentLargeAd.BadgeText, FallbackValue='스폰서 추천'}"
|
||||||
|
FontSize="10"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource FsAccentBlue}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Width="22" Height="22"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Style="{StaticResource FsIconButtonStyle}"
|
||||||
|
Command="{Binding DismissLargeCardCommand}"
|
||||||
|
ToolTip="광고 닫기">
|
||||||
|
<ui:SymbolIcon Symbol="Dismiss24"
|
||||||
|
FontSize="10"
|
||||||
|
Foreground="{StaticResource FsTextTertiary}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 2. 본문: 아이콘/이미지 + 타이틀 + 설명 -->
|
||||||
|
<Grid Margin="0,0,0,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Width="36" Height="36"
|
||||||
|
CornerRadius="8"
|
||||||
|
Background="{StaticResource FsBgInput}"
|
||||||
|
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||||
|
BorderThickness="1"
|
||||||
|
Margin="0,0,12,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Image Width="20" Height="20"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality"
|
||||||
|
Source="pack://application:,,,/Everything2Everything;component/Assets/logo-mark.png"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CurrentLargeAd.Title, FallbackValue='비즈니스 미디어 솔루션'}"
|
||||||
|
Style="{StaticResource FsLabelStyle}"
|
||||||
|
Foreground="{StaticResource FsTextPrimary}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="13"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding CurrentLargeAd.Description, FallbackValue='고성능 무손실 일괄 변환을 업무 환경에 적용하세요.'}"
|
||||||
|
Style="{StaticResource FsCaptionStyle}"
|
||||||
|
Foreground="{StaticResource FsTextSecondary}"
|
||||||
|
Margin="0,3,0,0"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 3. 하단 전면 CTA 버튼 -->
|
||||||
|
<Button Style="{StaticResource FsIslandPrimaryButtonStyle}"
|
||||||
|
Height="34"
|
||||||
|
Command="{Binding ClickLargeAdCommand}"
|
||||||
|
ToolTip="자세히 보기">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CurrentLargeAd.CtaText, FallbackValue='솔루션 살펴보기'}"
|
||||||
|
FontSize="12"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<ui:SymbolIcon Symbol="ArrowRight24"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="6,0,0,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.Views;
|
||||||
|
|
||||||
|
public partial class AdLargeCardControl : UserControl
|
||||||
|
{
|
||||||
|
public AdLargeCardControl()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,7 +50,7 @@ public static class CategoryGlyphs
|
||||||
public static ImageSource ForCategory(string category)
|
public static ImageSource ForCategory(string category)
|
||||||
{
|
{
|
||||||
if (Cache.TryGetValue(category, out var cached)) return cached;
|
if (Cache.TryGetValue(category, out var cached)) return cached;
|
||||||
var uri = new Uri($"pack://application:,,,/Assets/glyph-{category}.png", UriKind.Absolute);
|
var uri = new Uri($"pack://application:,,,/Everything2Everything;component/Assets/glyph-{category}.png", UriKind.Absolute);
|
||||||
var img = new BitmapImage();
|
var img = new BitmapImage();
|
||||||
img.BeginInit();
|
img.BeginInit();
|
||||||
img.CacheOption = BitmapCacheOption.OnLoad;
|
img.CacheOption = BitmapCacheOption.OnLoad;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ui:ControlsDictionary/>
|
||||||
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,100 @@
|
||||||
<Setter Property="Margin" Value="0,0,0,16"/>
|
<Setter Property="Margin" Value="0,0,0,16"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Double-Bezel (Doppelrand) Machine-Hardware Container Styles (high-end-visual-design Section 4) -->
|
||||||
|
<Style x:Key="FsDoubleBezelShellStyle" TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="#10131B"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource FsBorderHairline}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="CornerRadius" Value="14"/>
|
||||||
|
<Setter Property="Padding" Value="4"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,0,16"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="FsDoubleBezelCoreStyle" TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="{StaticResource FsBgSurface}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource FsGlassBorderBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="CornerRadius" Value="10"/>
|
||||||
|
<Setter Property="Padding" Value="14"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Physical <kbd> Keycap Shortcut Style (minimalist-ui Section 5) -->
|
||||||
|
<Style x:Key="FsKbdStyle" TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="{StaticResource FsBgInput}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource FsBorderStrong}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1,1,2,1"/>
|
||||||
|
<Setter Property="CornerRadius" Value="4"/>
|
||||||
|
<Setter Property="Padding" Value="6,2"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,4,0"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Island Button-in-Button Primary Launch CTA Style (high-end-visual-design Section 4.B) -->
|
||||||
|
<LinearGradientBrush x:Key="FsIslandButtonGlow" StartPoint="0,0" EndPoint="1,1">
|
||||||
|
<GradientStop Color="#0284C7" Offset="0.0"/>
|
||||||
|
<GradientStop Color="#0369A1" Offset="1.0"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
<LinearGradientBrush x:Key="FsIslandButtonGlowHover" StartPoint="0,0" EndPoint="1,1">
|
||||||
|
<GradientStop Color="#0EA5E9" Offset="0.0"/>
|
||||||
|
<GradientStop Color="#0284C7" Offset="1.0"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
|
||||||
|
<Style x:Key="FsIslandPrimaryButtonStyle" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="{StaticResource FsIslandButtonGlow}"/>
|
||||||
|
<Setter Property="Foreground" Value="#FFFFFF"/>
|
||||||
|
<Setter Property="BorderBrush" Value="#4038BDF8"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Height" Value="44"/>
|
||||||
|
<Setter Property="Padding" Value="16,0,10,0"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="PART_Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="10"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<Grid VerticalAlignment="Center">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<ContentPresenter Grid.Column="0"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextElement.Foreground="{TemplateBinding Foreground}"
|
||||||
|
TextElement.FontSize="{TemplateBinding FontSize}"
|
||||||
|
TextElement.FontWeight="{TemplateBinding FontWeight}"/>
|
||||||
|
<Border Grid.Column="1" Margin="8,0,0,0"
|
||||||
|
Width="26" Height="26" CornerRadius="13"
|
||||||
|
Background="#22FFFFFF"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Right">
|
||||||
|
<ui:SymbolIcon Symbol="ArrowRight24" FontSize="13"
|
||||||
|
Foreground="#FFFFFF"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter TargetName="PART_Bd" Property="Opacity" Value="0.45"/>
|
||||||
|
<Setter Property="Cursor" Value="Arrow"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="PART_Bd" Property="Background" Value="{StaticResource FsIslandButtonGlowHover}"/>
|
||||||
|
<Setter TargetName="PART_Bd" Property="BorderBrush" Value="#8038BDF8"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- 실시간 기술 스펙 칩 (Live Spec Chip) 캡슐 스타일 -->
|
<!-- 실시간 기술 스펙 칩 (Live Spec Chip) 캡슐 스타일 -->
|
||||||
<Style x:Key="FsSpecChipStyle" TargetType="Border">
|
<Style x:Key="FsSpecChipStyle" TargetType="Border">
|
||||||
<Setter Property="Background" Value="{StaticResource FsAccentCyanBg}"/>
|
<Setter Property="Background" Value="{StaticResource FsAccentCyanBg}"/>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -12,14 +12,37 @@ using Everything2Everything.App.Shell;
|
||||||
using Everything2Everything.App.ViewModels;
|
using Everything2Everything.App.ViewModels;
|
||||||
using Everything2Everything.Core;
|
using Everything2Everything.Core;
|
||||||
using Everything2Everything.Core.Filters;
|
using Everything2Everything.Core.Filters;
|
||||||
|
using Everything2Everything.Core.Ads;
|
||||||
using Everything2Everything.Core.Inspector;
|
using Everything2Everything.Core.Inspector;
|
||||||
using Everything2Everything.Core.Presets;
|
using Everything2Everything.Core.Presets;
|
||||||
using LossClass = Everything2Everything.Core.Providers.LossClass;
|
using LossClass = Everything2Everything.Core.Providers.LossClass;
|
||||||
|
|
||||||
namespace Everything2Everything.App.Views;
|
namespace Everything2Everything.App.Views;
|
||||||
|
|
||||||
public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
|
||||||
|
private bool _hasSelectedItem;
|
||||||
|
public bool HasSelectedItem
|
||||||
|
{
|
||||||
|
get => _hasSelectedItem;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_hasSelectedItem != value)
|
||||||
|
{
|
||||||
|
_hasSelectedItem = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand ClearSearchCommand { get; }
|
||||||
|
public ICommand SelectAllQueueCommand => BatchSelectAllCommand;
|
||||||
|
public ICommand DeleteSelectedQueueCommand => BatchRemoveSelectedCommand;
|
||||||
|
|
||||||
private readonly ConversionEngine _engine;
|
private readonly ConversionEngine _engine;
|
||||||
private readonly ISettingsStore _settings;
|
private readonly ISettingsStore _settings;
|
||||||
private readonly OptionsViewModel _options = new();
|
private readonly OptionsViewModel _options = new();
|
||||||
|
|
@ -112,15 +135,22 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
if (_selectedCategory != value)
|
if (_selectedCategory != value)
|
||||||
{
|
{
|
||||||
_selectedCategory = value;
|
_selectedCategory = value;
|
||||||
|
if (CategoryFilterCombo != null && CategoryFilterCombo.SelectedIndex != (int)value)
|
||||||
|
{
|
||||||
|
CategoryFilterCombo.SelectedIndex = (int)value;
|
||||||
|
}
|
||||||
ApplyQueueFilters();
|
ApplyQueueFilters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList<string>? initialFiles = null)
|
public AdViewModel AdVm { get; }
|
||||||
|
|
||||||
|
public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList<string>? initialFiles = null, IAdService? adService = null)
|
||||||
{
|
{
|
||||||
_engine = engine;
|
_engine = engine;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
AdVm = new AdViewModel(adService ?? new AdService());
|
||||||
|
|
||||||
AddFilesCommand = new RelayCommand(_ => PickAndAddFiles());
|
AddFilesCommand = new RelayCommand(_ => PickAndAddFiles());
|
||||||
ProcessQueueCommand = new RelayCommand(_ => OnProcessQueueClick(this, new RoutedEventArgs()),
|
ProcessQueueCommand = new RelayCommand(_ => OnProcessQueueClick(this, new RoutedEventArgs()),
|
||||||
|
|
@ -145,7 +175,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
ToggleInspectorCommand = new RelayCommand(_ => ToggleInspector());
|
ToggleInspectorCommand = new RelayCommand(_ => ToggleInspector());
|
||||||
RemoveQueueItemCommand = new RelayCommand(p => RemoveQueueItem(p as QueueItem));
|
RemoveQueueItemCommand = new RelayCommand(p => RemoveQueueItem(p as QueueItem));
|
||||||
OpenFolderCommand = new RelayCommand(p => OpenFolderForPath(p as string));
|
OpenFolderCommand = new RelayCommand(p => OpenFolderForPath(p as string));
|
||||||
TabCommand = new RelayCommand(p => ShowTab(p as string ?? "Past"));
|
TabCommand = new RelayCommand(p => ShowTab(p as string ?? "Active"));
|
||||||
ConflictRuleCommand = new RelayCommand(p => SetConflictRule(p as string));
|
ConflictRuleCommand = new RelayCommand(p => SetConflictRule(p as string));
|
||||||
CombineToggleCommand = new RelayCommand(_ => UpdateCombineState(SelectedOutputExtension));
|
CombineToggleCommand = new RelayCommand(_ => UpdateCombineState(SelectedOutputExtension));
|
||||||
OutputFormatChangedCommand = new RelayCommand(_ => OnOutputFormatSelected());
|
OutputFormatChangedCommand = new RelayCommand(_ => OnOutputFormatSelected());
|
||||||
|
|
@ -153,15 +183,34 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
DragLeaveCommand = new RelayCommand(_ => DropHintOverlay.Visibility = Visibility.Collapsed);
|
DragLeaveCommand = new RelayCommand(_ => DropHintOverlay.Visibility = Visibility.Collapsed);
|
||||||
DropCommand = new RelayCommand(p => HandleFilesDropped(p as DragEventArgs));
|
DropCommand = new RelayCommand(p => HandleFilesDropped(p as DragEventArgs));
|
||||||
QueueRowCommand = new RelayCommand(p => HandleQueueRowClick(p as MouseButtonEventArgs));
|
QueueRowCommand = new RelayCommand(p => HandleQueueRowClick(p as MouseButtonEventArgs));
|
||||||
|
ClearSearchCommand = new RelayCommand(_ =>
|
||||||
|
{
|
||||||
|
SearchText = "";
|
||||||
|
if (SearchBox is not null) SearchBox.Text = "";
|
||||||
|
});
|
||||||
PastRowCommand = new RelayCommand(p => HandlePastRowClick(p as MouseButtonEventArgs));
|
PastRowCommand = new RelayCommand(p => HandlePastRowClick(p as MouseButtonEventArgs));
|
||||||
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
Title = "Everything2Everything";
|
||||||
|
if (AppTitleBar is not null) AppTitleBar.Title = "Everything2Everything";
|
||||||
|
|
||||||
|
if (AdvancedOptionsExpander is not null)
|
||||||
|
{
|
||||||
|
AdvancedOptionsExpander.IsExpanded = true;
|
||||||
|
AdvancedOptionsExpander.Expanded += (_, _) => _options.IsAdvancedExpanded = true;
|
||||||
|
AdvancedOptionsExpander.Collapsed += (_, _) => _options.IsAdvancedExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (SmartPresetCombo is not null)
|
if (SmartPresetCombo is not null)
|
||||||
{
|
{
|
||||||
SmartPresetCombo.SelectionChanged += OnSmartPresetChanged;
|
SmartPresetCombo.SelectionChanged += OnSmartPresetChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (OutputFormatCombo is not null)
|
||||||
|
{
|
||||||
|
OutputFormatCombo.SelectionChanged += (_, _) => OnOutputFormatSelected();
|
||||||
|
}
|
||||||
|
|
||||||
// ActiveQueueList/PastResultsList의 ItemsSource는 XAML이 ActiveQueue/PastResults에 바인딩(선언적).
|
// ActiveQueueList/PastResultsList의 ItemsSource는 XAML이 ActiveQueue/PastResults에 바인딩(선언적).
|
||||||
|
|
||||||
InitializeOutputFormats();
|
InitializeOutputFormats();
|
||||||
|
|
@ -202,7 +251,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
CapabilityStatusText.Text = $"⚠ {notReady.Count}개 형식이 외부 도구를 기다립니다 (Diagnose 참조)";
|
CapabilityStatusText.Text = $"⚠ {notReady.Count}개 형식이 외부 도구를 기다립니다 (진단 도구 참조)";
|
||||||
CapabilityStatusText.Visibility = Visibility.Visible;
|
CapabilityStatusText.Visibility = Visibility.Visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,7 +337,9 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
foreach (var path in paths)
|
foreach (var path in paths)
|
||||||
{
|
{
|
||||||
if (!File.Exists(path) || existing.Contains(path)) continue;
|
if (!File.Exists(path) || existing.Contains(path)) continue;
|
||||||
_activeQueue.Add(QueueItem.FromPath(path));
|
var item = QueueItem.FromPath(path);
|
||||||
|
item.PropertyChanged += OnQueueItemPropertyChanged;
|
||||||
|
_activeQueue.Add(item);
|
||||||
}
|
}
|
||||||
UpdateBadges();
|
UpdateBadges();
|
||||||
UpdateProcessQueueButton();
|
UpdateProcessQueueButton();
|
||||||
|
|
@ -302,9 +353,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnQueueItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(QueueItem.IsSelected))
|
||||||
|
{
|
||||||
|
UpdateQueueSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void RemoveQueueItem(QueueItem? item)
|
private void RemoveQueueItem(QueueItem? item)
|
||||||
{
|
{
|
||||||
if (item is null) return;
|
if (item is null) return;
|
||||||
|
item.PropertyChanged -= OnQueueItemPropertyChanged;
|
||||||
_activeQueue.Remove(item);
|
_activeQueue.Remove(item);
|
||||||
UpdateBadges();
|
UpdateBadges();
|
||||||
UpdateProcessQueueButton();
|
UpdateProcessQueueButton();
|
||||||
|
|
@ -336,6 +396,40 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
var count = _pastResults.Sum(g => g.Entries.Count);
|
var count = _pastResults.Sum(g => g.Entries.Count);
|
||||||
TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture);
|
TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture);
|
||||||
UpdatePastResultsVisibility();
|
UpdatePastResultsVisibility();
|
||||||
|
UpdateQueueSummary();
|
||||||
|
UpdatePastResultsTelemetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateQueueSummary()
|
||||||
|
{
|
||||||
|
if (QueueSummaryCountText is null || QueueSummarySizeText is null) return;
|
||||||
|
var count = _activeQueue.Count;
|
||||||
|
long totalBytes = _activeQueue.Sum(q => q.SourceSizeBytes);
|
||||||
|
QueueSummaryCountText.Text = $"총 {count}개 항목";
|
||||||
|
QueueSummarySizeText.Text = HumanizeBytes(totalBytes);
|
||||||
|
|
||||||
|
var selectedCount = _activeQueue.Count(q => q.IsSelected);
|
||||||
|
if (QueueSelectedBadge is not null && QueueSelectedText is not null)
|
||||||
|
{
|
||||||
|
if (selectedCount > 0)
|
||||||
|
{
|
||||||
|
QueueSelectedBadge.Visibility = Visibility.Visible;
|
||||||
|
QueueSelectedText.Text = $"{selectedCount}개 선택됨";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
QueueSelectedBadge.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePastResultsTelemetry()
|
||||||
|
{
|
||||||
|
if (PastTotalCountText is null || PastTotalSavingsText is null) return;
|
||||||
|
var totalCount = _pastResults.Sum(g => g.Entries.Count);
|
||||||
|
var totalSavings = _pastResults.Sum(g => g.SessionSavingsBytes);
|
||||||
|
PastTotalCountText.Text = $"{totalCount}개 파일";
|
||||||
|
PastTotalSavingsText.Text = HumanizeBytes(totalSavings);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateProcessQueueButton()
|
private void UpdateProcessQueueButton()
|
||||||
|
|
@ -343,22 +437,26 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
var count = _activeQueue.Count;
|
var count = _activeQueue.Count;
|
||||||
if (_cts is not null)
|
if (_cts is not null)
|
||||||
{
|
{
|
||||||
ProcessQueueButton.Content = $"변환 처리 중… ({count}개 파일)";
|
ProcessQueueButton.Content = $"변환 처리 중… ({count}개)";
|
||||||
|
ProcessQueueButton.ToolTip = "파일 변환이 진행 중입니다.";
|
||||||
ProcessQueueButton.IsEnabled = false;
|
ProcessQueueButton.IsEnabled = false;
|
||||||
}
|
}
|
||||||
else if (count == 0)
|
else if (count == 0)
|
||||||
{
|
{
|
||||||
ProcessQueueButton.Content = "대기 중 — 파일을 드래그하여 추가하세요";
|
ProcessQueueButton.Content = "파일을 드래그하여 추가";
|
||||||
|
ProcessQueueButton.ToolTip = "대기열에 변환할 파일을 추가하세요 (단축키: Ctrl + O)";
|
||||||
ProcessQueueButton.IsEnabled = false;
|
ProcessQueueButton.IsEnabled = false;
|
||||||
}
|
}
|
||||||
else if (string.IsNullOrEmpty(SelectedOutputExtension))
|
else if (string.IsNullOrEmpty(SelectedOutputExtension))
|
||||||
{
|
{
|
||||||
ProcessQueueButton.Content = "변환 불가 (공통 형식 없음)";
|
ProcessQueueButton.Content = "공통 형식 없음";
|
||||||
|
ProcessQueueButton.ToolTip = "선택된 파일들 간에 호환 가능한 공통 출력 형식이 없습니다.";
|
||||||
ProcessQueueButton.IsEnabled = false;
|
ProcessQueueButton.IsEnabled = false;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ProcessQueueButton.Content = $"대기열 일괄 변환 시작 ({count}개) [Ctrl + Enter]";
|
ProcessQueueButton.Content = $"변환 시작 ({count}개 파일)";
|
||||||
|
ProcessQueueButton.ToolTip = $"대기열 일괄 변환 시작 ({count}개 파일) [단축키: Ctrl + Enter]";
|
||||||
ProcessQueueButton.IsEnabled = true;
|
ProcessQueueButton.IsEnabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -505,10 +603,44 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
var info = FileInspectorBuilder.Build(filePath);
|
var info = FileInspectorBuilder.Build(filePath);
|
||||||
PreviewFileName.Text = string.IsNullOrEmpty(fileName) ? info.FileName : fileName;
|
PreviewFileName.Text = string.IsNullOrEmpty(fileName) ? info.FileName : fileName;
|
||||||
PreviewFilePath.Text = string.IsNullOrEmpty(filePath) ? info.FullPath : filePath;
|
PreviewFilePath.Text = string.IsNullOrEmpty(filePath) ? info.FullPath : filePath;
|
||||||
PreviewFormatText.Text = string.IsNullOrEmpty(formatLabel) || formatLabel == "—" ? info.Extension.TrimStart('.').ToUpperInvariant() : formatLabel;
|
var fmt = string.IsNullOrEmpty(formatLabel) || formatLabel == "—" ? info.Extension.TrimStart('.').ToUpperInvariant() : formatLabel;
|
||||||
PreviewSizeText.Text = string.IsNullOrEmpty(sizeText) || sizeText == "—" ? info.FormattedSize : sizeText;
|
var sz = string.IsNullOrEmpty(sizeText) || sizeText == "—" ? info.FormattedSize : sizeText;
|
||||||
|
PreviewFormatText.Text = fmt;
|
||||||
|
PreviewSizeText.Text = sz;
|
||||||
PreviewDimText.Text = info.DimensionsOrMeta;
|
PreviewDimText.Text = info.DimensionsOrMeta;
|
||||||
PreviewPageText.Text = info.Category.ToString();
|
PreviewPageText.Text = info.Category.ToKoreanLabel();
|
||||||
|
UpdateConversionPipelineCard(fmt, sz);
|
||||||
|
HasSelectedItem = !string.IsNullOrEmpty(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateConversionPipelineCard(string? formatLabel, string? sizeText)
|
||||||
|
{
|
||||||
|
if (ConversionPipelineCard is null || PipelineSourceText is null || PipelineTargetText is null) return;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(formatLabel) || formatLabel == "—")
|
||||||
|
{
|
||||||
|
PipelineSourceText.Text = "선택 대기";
|
||||||
|
PipelineTargetText.Text = (SelectedOutputExtension ?? ".jpg").TrimStart('.').ToUpperInvariant();
|
||||||
|
if (PipelineLossText is not null) PipelineLossText.Text = "파일을 선택하면 변환 정보 표시";
|
||||||
|
if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "대기 중";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PipelineSourceText.Text = $"{formatLabel} · {sizeText ?? "—"}";
|
||||||
|
var targetExt = (SelectedOutputExtension ?? ".jpg").TrimStart('.').ToUpperInvariant();
|
||||||
|
PipelineTargetText.Text = targetExt;
|
||||||
|
|
||||||
|
var isSame = string.Equals(formatLabel, targetExt, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (isSame)
|
||||||
|
{
|
||||||
|
if (PipelineLossText is not null) PipelineLossText.Text = "무손실 재압축 및 메타 정돈";
|
||||||
|
if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "최적화";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (PipelineLossText is not null) PipelineLossText.Text = $"{formatLabel} → {targetExt} 변환";
|
||||||
|
if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "실시간 매칭";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowPreviewLoading()
|
private void ShowPreviewLoading()
|
||||||
|
|
@ -704,6 +836,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnCategoryFilterSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (CategoryFilterCombo?.SelectedItem is ComboBoxItem item && item.Tag is string tag)
|
||||||
|
{
|
||||||
|
ApplyFilterCategory(tag);
|
||||||
|
}
|
||||||
|
else if (CategoryFilterCombo != null && CategoryFilterCombo.SelectedIndex >= 0)
|
||||||
|
{
|
||||||
|
SelectedCategory = (FilterCategory)CategoryFilterCombo.SelectedIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyFilterCategory(string? categoryName)
|
private void ApplyFilterCategory(string? categoryName)
|
||||||
{
|
{
|
||||||
if (Enum.TryParse<FilterCategory>(categoryName, true, out var cat))
|
if (Enum.TryParse<FilterCategory>(categoryName, true, out var cat))
|
||||||
|
|
@ -733,6 +877,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
{
|
{
|
||||||
bool select = parameter is true;
|
bool select = parameter is true;
|
||||||
BatchQueueService.SetSelectionAll(_activeQueue, select);
|
BatchQueueService.SetSelectionAll(_activeQueue, select);
|
||||||
|
UpdateQueueSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BatchRemoveSelected()
|
private void BatchRemoveSelected()
|
||||||
|
|
@ -1043,10 +1188,9 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
private static string FormatDateLabel(DateOnly date)
|
private static string FormatDateLabel(DateOnly date)
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
var label = date == today ? "Today"
|
if (date == today) return $"오늘 ({date:M월 d일})";
|
||||||
: date == today.AddDays(-1) ? "Yesterday"
|
if (date == today.AddDays(-1)) return $"어제 ({date:M월 d일})";
|
||||||
: date.ToString("dddd", CultureInfo.GetCultureInfo("en-US"));
|
return date.ToString("yyyy년 M월 d일 (ddd)", CultureInfo.GetCultureInfo("ko-KR"));
|
||||||
return $"{label}, {date:MMM d}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyAppDataStats()
|
private void ApplyAppDataStats()
|
||||||
|
|
@ -1098,7 +1242,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
else if (TabPastBtn.IsChecked == true)
|
else if (TabPastBtn.IsChecked == true)
|
||||||
{
|
{
|
||||||
var confirm = MessageBox.Show(this,
|
var confirm = MessageBox.Show(this,
|
||||||
"Past Results 전체를 삭제하시겠습니까?\n영구 저장된 이력도 함께 삭제됩니다.",
|
"변환 기록 전체를 삭제하시겠습니까?\n영구 저장된 이력도 함께 삭제됩니다.",
|
||||||
"Everything2Everything",
|
"Everything2Everything",
|
||||||
MessageBoxButton.OKCancel, MessageBoxImage.Question);
|
MessageBoxButton.OKCancel, MessageBoxImage.Question);
|
||||||
if (confirm != MessageBoxResult.OK) return;
|
if (confirm != MessageBoxResult.OK) return;
|
||||||
|
|
@ -1317,6 +1461,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
UpdateCombineState(keepExt);
|
UpdateCombineState(keepExt);
|
||||||
UpdateProcessQueueButton();
|
UpdateProcessQueueButton();
|
||||||
UpdateSmartPresetsForFormat(keepExt);
|
UpdateSmartPresetsForFormat(keepExt);
|
||||||
|
UpdateConversionPipelineCard(PreviewFormatText?.Text, PreviewSizeText?.Text);
|
||||||
|
|
||||||
if (OutputFormatHint is not null)
|
if (OutputFormatHint is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -1362,6 +1507,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
UpdateQualityPanelForFormat(ext);
|
UpdateQualityPanelForFormat(ext);
|
||||||
UpdateOutputDestHint(ext);
|
UpdateOutputDestHint(ext);
|
||||||
UpdateSmartPresetsForFormat(ext);
|
UpdateSmartPresetsForFormat(ext);
|
||||||
|
UpdateConversionPipelineCard(PreviewFormatText?.Text, PreviewSizeText?.Text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1386,21 +1532,53 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
|
|
||||||
private void UpdateQualityPanelForFormat(string? extension)
|
private void UpdateQualityPanelForFormat(string? extension)
|
||||||
{
|
{
|
||||||
if (QualityPanel is null || QualityLabelText is null) return;
|
|
||||||
var ext = extension?.ToLowerInvariant();
|
var ext = extension?.ToLowerInvariant();
|
||||||
var supportsQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif";
|
var isImageQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif";
|
||||||
QualityPanel.Visibility = supportsQuality ? Visibility.Visible : Visibility.Collapsed;
|
var isVideo = ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi";
|
||||||
|
var isAudio = ext is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg" or ".flac" or ".wav";
|
||||||
|
var isPdf = ext is ".pdf";
|
||||||
|
|
||||||
|
if (QualityPanel is not null)
|
||||||
|
{
|
||||||
|
QualityPanel.Visibility = isImageQuality ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
if (QualityLabelText is not null)
|
||||||
|
{
|
||||||
QualityLabelText.Text = ext switch
|
QualityLabelText.Text = ext switch
|
||||||
{
|
{
|
||||||
".jpg" or ".jpeg" => "JPEG QUALITY",
|
".jpg" or ".jpeg" => "JPEG 압축 품질",
|
||||||
".webp" => "WEBP QUALITY",
|
".webp" => "WebP 압축 품질",
|
||||||
".avif" => "AVIF QUALITY",
|
".avif" => "AVIF 압축 품질",
|
||||||
_ => "ENCODING QUALITY",
|
_ => "압축 품질",
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VideoQuickPanel is not null)
|
||||||
|
VideoQuickPanel.Visibility = isVideo ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
if (AudioQuickPanel is not null)
|
||||||
|
AudioQuickPanel.Visibility = isAudio ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
if (PdfQuickPanel is not null)
|
||||||
|
PdfQuickPanel.Visibility = isPdf ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
|
var isText = ext is ".txt" or ".md";
|
||||||
|
if (AiQuickPanel is not null)
|
||||||
|
AiQuickPanel.Visibility = isText ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
if (AiTargetLanguagePanel is not null && AiTaskQuickCombo is not null)
|
||||||
|
AiTargetLanguagePanel.Visibility = (isText && AiTaskQuickCombo.SelectedIndex == 1)
|
||||||
|
? Visibility.Visible
|
||||||
|
: Visibility.Collapsed;
|
||||||
|
|
||||||
UpdateMediaPanelForFormat(extension);
|
UpdateMediaPanelForFormat(extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnAiTaskSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (AiTargetLanguagePanel is null || AiTaskQuickCombo is null) return;
|
||||||
|
AiTargetLanguagePanel.Visibility = AiTaskQuickCombo.SelectedIndex == 1
|
||||||
|
? Visibility.Visible
|
||||||
|
: Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>출력이 영상/오디오/PDF/이미지일 때 상세 인코딩 폴드아웃 서브패널 노출(Fluent 2 점진적 공개 패턴).</summary>
|
/// <summary>출력이 영상/오디오/PDF/이미지일 때 상세 인코딩 폴드아웃 서브패널 노출(Fluent 2 점진적 공개 패턴).</summary>
|
||||||
private void UpdateMediaPanelForFormat(string? extension)
|
private void UpdateMediaPanelForFormat(string? extension)
|
||||||
{
|
{
|
||||||
|
|
@ -1471,9 +1649,17 @@ public sealed class QueueItem : INotifyPropertyChanged
|
||||||
public string StateText
|
public string StateText
|
||||||
{
|
{
|
||||||
get => _state;
|
get => _state;
|
||||||
set { _state = value; Raise(nameof(StateText)); Raise(nameof(IsDone)); }
|
set { _state = value; Raise(nameof(StateText)); Raise(nameof(DisplayStateText)); Raise(nameof(IsDone)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>사용자에게 표시되는 정제된 한국어 상태 텍스트 (AGENTS.md Rule 3).</summary>
|
||||||
|
public string DisplayStateText => _state switch
|
||||||
|
{
|
||||||
|
"queued" => "대기 중",
|
||||||
|
"done" => "변환 완료",
|
||||||
|
_ => _state,
|
||||||
|
};
|
||||||
|
|
||||||
public Brush StateBrush => _state switch
|
public Brush StateBrush => _state switch
|
||||||
{
|
{
|
||||||
"queued" => (Application.Current?.TryFindResource("FsTextTertiary") as Brush) ?? Brushes.Gray,
|
"queued" => (Application.Current?.TryFindResource("FsTextTertiary") as Brush) ?? Brushes.Gray,
|
||||||
|
|
@ -1547,7 +1733,7 @@ public sealed class DateGroup : INotifyPropertyChanged
|
||||||
public string DateTitle { get; }
|
public string DateTitle { get; }
|
||||||
public ObservableCollection<HistoryRow> Entries { get; } = new();
|
public ObservableCollection<HistoryRow> Entries { get; } = new();
|
||||||
public long SessionSavingsBytes { get; set; }
|
public long SessionSavingsBytes { get; set; }
|
||||||
public string SessionSavingsText => $"Session Savings: {MainWindow.HumanizeBytes(SessionSavingsBytes)}";
|
public string SessionSavingsText => $"세션 절감: {MainWindow.HumanizeBytes(SessionSavingsBytes)}";
|
||||||
|
|
||||||
public DateGroup(string dateTitle) { DateTitle = dateTitle; }
|
public DateGroup(string dateTitle) { DateTitle = dateTitle; }
|
||||||
|
|
||||||
|
|
@ -1591,7 +1777,7 @@ public sealed record HistoryRow(
|
||||||
FormatLabel: label,
|
FormatLabel: label,
|
||||||
FormatBrush: brush,
|
FormatBrush: brush,
|
||||||
FileName: Path.GetFileName(e.SourcePath),
|
FileName: Path.GetFileName(e.SourcePath),
|
||||||
MetaLine: $"{e.Timestamp:HH:mm:ss} • {e.OutputCount} output(s)",
|
MetaLine: $"{e.Timestamp:HH:mm:ss} • {e.OutputCount}개 파일",
|
||||||
SizeText: MainWindow.HumanizeBytes(e.SourceSizeBytes),
|
SizeText: MainWindow.HumanizeBytes(e.SourceSizeBytes),
|
||||||
SavingsText: $"{arrow} {MainWindow.HumanizeBytes(Math.Abs(saved))}",
|
SavingsText: $"{arrow} {MainWindow.HumanizeBytes(Math.Abs(saved))}",
|
||||||
SourcePath: e.SourcePath,
|
SourcePath: e.SourcePath,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ui:ControlsDictionary/>
|
||||||
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|
@ -73,6 +74,20 @@
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- AI 텍스트 처리 (txt/md) -->
|
||||||
|
<StackPanel x:Name="AiQuickPanel" Margin="0,18,0,0" Visibility="Collapsed">
|
||||||
|
<TextBlock Text="AI 작업 선택" Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
|
||||||
|
<ComboBox SelectedIndex="{Binding AiTaskIndex, Mode=TwoWay}">
|
||||||
|
<ComboBoxItem Content="핵심 요약 (Summarize)"/>
|
||||||
|
<ComboBoxItem Content="자연스러운 번역 (Translate)"/>
|
||||||
|
<ComboBoxItem Content="오탈자 및 문법 교정 (Proofread)"/>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBox Margin="0,8,0,0" Style="{StaticResource FsPathInputStyle}"
|
||||||
|
Padding="10,6"
|
||||||
|
Text="{Binding TargetLanguage, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
ToolTip="번역 시 대상 언어 (예: 영어, 일본어, 중국어)"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- 버튼 -->
|
<!-- 버튼 -->
|
||||||
<Grid Margin="0,22,0,0">
|
<Grid Margin="0,22,0,0">
|
||||||
<Button HorizontalAlignment="Left" Content="자세히 옵션…"
|
<Button HorizontalAlignment="Left" Content="자세히 옵션…"
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,12 @@ public partial class QuickOptionsWindow : FluentWindow
|
||||||
var isVideo = ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi";
|
var isVideo = ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi";
|
||||||
var isLossyAudio = ext is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg";
|
var isLossyAudio = ext is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg";
|
||||||
var isImageQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif";
|
var isImageQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif";
|
||||||
|
var isText = ext is ".txt" or ".md";
|
||||||
|
|
||||||
VideoQuickPanel.Visibility = isVideo ? Visibility.Visible : Visibility.Collapsed;
|
VideoQuickPanel.Visibility = isVideo ? Visibility.Visible : Visibility.Collapsed;
|
||||||
AudioQuickPanel.Visibility = (isVideo || isLossyAudio) ? Visibility.Visible : Visibility.Collapsed;
|
AudioQuickPanel.Visibility = (isVideo || isLossyAudio) ? Visibility.Visible : Visibility.Collapsed;
|
||||||
ImageQualityPanel.Visibility = isImageQuality ? Visibility.Visible : Visibility.Collapsed;
|
ImageQualityPanel.Visibility = isImageQuality ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
AiQuickPanel.Visibility = isText ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
// 영상의 오디오 트랙임을 구분
|
// 영상의 오디오 트랙임을 구분
|
||||||
if (isVideo) AudioPanelLabel.Text = "오디오 비트레이트";
|
if (isVideo) AudioPanelLabel.Text = "오디오 비트레이트";
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ui:ControlsDictionary/>
|
||||||
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ui:ControlsDictionary/>
|
||||||
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|
@ -29,9 +30,8 @@
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|
||||||
<!-- ===== AI 카드 ===== -->
|
<!-- ===== AI 카드 ===== -->
|
||||||
<Border Background="{StaticResource FsBgSurface}"
|
<Border Style="{StaticResource FsDoubleBezelShellStyle}" Margin="0,0,0,16">
|
||||||
BorderBrush="{StaticResource FsBorderHairline}" BorderThickness="1"
|
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="20">
|
||||||
CornerRadius="10" Padding="20" Margin="0,0,0,16">
|
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="AI 텍스트 변환" Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="15"/>
|
<TextBlock Text="AI 텍스트 변환" Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="15"/>
|
||||||
<TextBlock Text="요약 · 번역 · 교정에 사용됩니다 (종량 과금 · 네트워크 필요). 키가 없으면 AI 변환만 비활성됩니다."
|
<TextBlock Text="요약 · 번역 · 교정에 사용됩니다 (종량 과금 · 네트워크 필요). 키가 없으면 AI 변환만 비활성됩니다."
|
||||||
|
|
@ -39,12 +39,48 @@
|
||||||
|
|
||||||
<TextBlock Text="기본 백엔드" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
<TextBlock Text="기본 백엔드" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
||||||
<ComboBox x:Name="BackendCombo" Margin="0,0,0,16">
|
<ComboBox x:Name="BackendCombo" Margin="0,0,0,16">
|
||||||
<ComboBoxItem Content="자동 (API 키 우선, 없으면 Codex)"/>
|
<ComboBoxItem Content="자동 (API 키 우선 · Switchboard · Antigravity · Codex)"/>
|
||||||
|
<ComboBoxItem Content="Switchboard 게이트웨이 (http://127.0.0.1:8787 · 로컬/LAN)"/>
|
||||||
|
<ComboBoxItem Content="Antigravity CLI (agy · Gemini OAuth · 무료/구독)"/>
|
||||||
<ComboBoxItem Content="OpenAI (API 키)"/>
|
<ComboBoxItem Content="OpenAI (API 키)"/>
|
||||||
<ComboBoxItem Content="Anthropic (API 키)"/>
|
<ComboBoxItem Content="Anthropic (API 키)"/>
|
||||||
<ComboBoxItem Content="Codex CLI (ChatGPT 구독 OAuth)"/>
|
<ComboBoxItem Content="Codex CLI (ChatGPT 구독 OAuth)"/>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
|
||||||
|
<!-- Switchboard Gateway -->
|
||||||
|
<TextBlock Text="Switchboard 게이트웨이 (로컬/LAN 멀티에이전트 오케스트레이션)" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
||||||
|
<Grid Margin="0,0,0,6">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox x:Name="SwitchboardEndpointBox" Grid.Column="0"
|
||||||
|
Style="{StaticResource FsPathInputStyle}"
|
||||||
|
TextChanged="OnSwitchboardEndpointChanged"/>
|
||||||
|
<Button x:Name="SwitchboardVerifyBtn" Grid.Column="1" Content="테스트"
|
||||||
|
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
|
||||||
|
Padding="12,6" Click="OnVerifySwitchboard"/>
|
||||||
|
<Button x:Name="SwitchboardStartBtn" Grid.Column="2" Content="게이트웨이 기동"
|
||||||
|
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
|
||||||
|
Padding="12,6" Click="OnStartSwitchboardGateway"/>
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
|
||||||
|
<Ellipse x:Name="SwitchboardDot" Style="{StaticResource FsLossDotStyle}"
|
||||||
|
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="SwitchboardStatus" Text="확인 중…" Style="{StaticResource FsCaptionStyle}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Antigravity CLI (OAuth) -->
|
||||||
|
<TextBlock Text="Antigravity CLI (agy · Google OAuth · API 키 불필요)" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
|
||||||
|
<Ellipse x:Name="AgyDot" Style="{StaticResource FsLossDotStyle}"
|
||||||
|
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="AgyStatus" Text="확인 중…" Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
|
||||||
|
<Button x:Name="AgyVerifyBtn" Content="테스트" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||||
|
Margin="12,0,0,0" Padding="10,4" IsEnabled="False" Click="OnVerifyAgy" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- OpenAI -->
|
<!-- OpenAI -->
|
||||||
<TextBlock Text="OpenAI API Key" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
<TextBlock Text="OpenAI API Key" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
||||||
<Grid Margin="0,0,0,4">
|
<Grid Margin="0,0,0,4">
|
||||||
|
|
@ -102,15 +138,18 @@
|
||||||
Style="{StaticResource FsCaptionStyle}" Margin="0,6,0,0"/>
|
Style="{StaticResource FsCaptionStyle}" Margin="0,6,0,0"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ===== 외부 도구 카드 ===== -->
|
<!-- ===== 외부 도구 카드 ===== -->
|
||||||
<Border Background="{StaticResource FsBgSurface}"
|
<Border Style="{StaticResource FsDoubleBezelShellStyle}">
|
||||||
BorderBrush="{StaticResource FsBorderHairline}" BorderThickness="1"
|
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="20">
|
||||||
CornerRadius="10" Padding="20">
|
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="외부 도구" Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="15"/>
|
<TextBlock Text="외부 도구" Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="15"/>
|
||||||
<TextBlock Text="설치하면 영상/오디오·한글/Word 변환이 자동 활성화됩니다."
|
<TextBlock Text="설치하면 영상/오디오·한글/Word 변환이 자동 활성화됩니다."
|
||||||
Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,12"/>
|
Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,12"/>
|
||||||
|
<Button Content="외부 도구 설치 / 검증" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||||
|
Padding="14,6" HorizontalAlignment="Right" Margin="0,0,0,12"
|
||||||
|
Click="OnOpenToolInstaller"/>
|
||||||
<CheckBox x:Name="GpuToggle" Content="영상 변환 시 GPU 가속(NVENC) 시도 — 없으면 CPU 자동 전환"
|
<CheckBox x:Name="GpuToggle" Content="영상 변환 시 GPU 가속(NVENC) 시도 — 없으면 CPU 자동 전환"
|
||||||
Foreground="{StaticResource FsTextSecondary}" IsChecked="True" Margin="0,0,0,16"/>
|
Foreground="{StaticResource FsTextSecondary}" IsChecked="True" Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
|
@ -156,6 +195,7 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,15 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
var backend = (_settings.Get("ai.backend") ?? "auto").ToLowerInvariant();
|
var backend = (_settings.Get("ai.backend") ?? "auto").ToLowerInvariant();
|
||||||
BackendCombo.SelectedIndex = backend switch
|
BackendCombo.SelectedIndex = backend switch
|
||||||
{
|
{
|
||||||
"openai" => 1,
|
"switchboard" or "gateway" => 1,
|
||||||
"anthropic" => 2,
|
"agy" => 2,
|
||||||
"codex" => 3,
|
"openai" => 3,
|
||||||
|
"anthropic" => 4,
|
||||||
|
"codex" => 5,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
ModelBox.Text = _settings.Get("ai.model") ?? string.Empty;
|
ModelBox.Text = _settings.Get("ai.model") ?? string.Empty;
|
||||||
|
SwitchboardEndpointBox.Text = _settings.Get("switchboard.endpoint") ?? "http://127.0.0.1:8787";
|
||||||
|
|
||||||
SetKeyStatus(OpenAiDot, OpenAiStatus, _settings.Contains("openai.apikey"), HasEnv("OPENAI_API_KEY"));
|
SetKeyStatus(OpenAiDot, OpenAiStatus, _settings.Contains("openai.apikey"), HasEnv("OPENAI_API_KEY"));
|
||||||
SetKeyStatus(AnthropicDot, AnthropicStatus, _settings.Contains("anthropic.apikey"), HasEnv("ANTHROPIC_API_KEY"));
|
SetKeyStatus(AnthropicDot, AnthropicStatus, _settings.Contains("anthropic.apikey"), HasEnv("ANTHROPIC_API_KEY"));
|
||||||
|
|
@ -58,6 +61,9 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
private void OnAnthropicKeyChanged(object sender, RoutedEventArgs e)
|
private void OnAnthropicKeyChanged(object sender, RoutedEventArgs e)
|
||||||
=> AnthropicVerifyBtn.IsEnabled = AnthropicKeyBox.Password.StartsWith("sk-ant-", StringComparison.Ordinal);
|
=> AnthropicVerifyBtn.IsEnabled = AnthropicKeyBox.Password.StartsWith("sk-ant-", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private void OnSwitchboardEndpointChanged(object sender, TextChangedEventArgs e)
|
||||||
|
=> _ = CheckSwitchboardGatewayAsync();
|
||||||
|
|
||||||
private async void OnVerifyOpenAi(object sender, RoutedEventArgs e)
|
private async void OnVerifyOpenAi(object sender, RoutedEventArgs e)
|
||||||
=> await VerifyAsync(OpenAiDot, OpenAiStatus, OpenAiVerifyBtn,
|
=> await VerifyAsync(OpenAiDot, OpenAiStatus, OpenAiVerifyBtn,
|
||||||
new OpenAiChatClient(OpenAiKeyBox.Password), ModelOr("gpt-4o-mini"));
|
new OpenAiChatClient(OpenAiKeyBox.Password), ModelOr("gpt-4o-mini"));
|
||||||
|
|
@ -66,6 +72,63 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
=> await VerifyAsync(AnthropicDot, AnthropicStatus, AnthropicVerifyBtn,
|
=> await VerifyAsync(AnthropicDot, AnthropicStatus, AnthropicVerifyBtn,
|
||||||
new AnthropicChatClient(AnthropicKeyBox.Password), ModelOr("claude-3-5-sonnet-latest"));
|
new AnthropicChatClient(AnthropicKeyBox.Password), ModelOr("claude-3-5-sonnet-latest"));
|
||||||
|
|
||||||
|
private async void OnVerifySwitchboard(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var ep = string.IsNullOrWhiteSpace(SwitchboardEndpointBox.Text)
|
||||||
|
? (_settings.Get("switchboard.endpoint") ?? "http://127.0.0.1:8787")
|
||||||
|
: SwitchboardEndpointBox.Text.Trim();
|
||||||
|
await VerifyAsync(SwitchboardDot, SwitchboardStatus, SwitchboardVerifyBtn,
|
||||||
|
new SwitchboardChatClient(ep), ModelOr(string.Empty));
|
||||||
|
await CheckSwitchboardGatewayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStartSwitchboardGateway(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
@"D:\workspace\Switchboard\start-gateway-lan.cmd",
|
||||||
|
@"D:\workspace\Switchboard\start-gateway-agy.cmd",
|
||||||
|
@"D:\workspace\Switchboard\start-gateway.cmd",
|
||||||
|
};
|
||||||
|
string? cmdPath = candidates.FirstOrDefault(System.IO.File.Exists);
|
||||||
|
if (cmdPath is null)
|
||||||
|
{
|
||||||
|
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
var relativeCandidates = new[]
|
||||||
|
{
|
||||||
|
System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "..", "..", "..", "..", "Switchboard", "start-gateway-lan.cmd")),
|
||||||
|
System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "..", "..", "..", "..", "Switchboard", "start-gateway-agy.cmd")),
|
||||||
|
System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "..", "..", "..", "..", "Switchboard", "start-gateway.cmd")),
|
||||||
|
};
|
||||||
|
cmdPath = relativeCandidates.FirstOrDefault(System.IO.File.Exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmdPath is not null)
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = cmdPath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
WorkingDirectory = System.IO.Path.GetDirectoryName(cmdPath)!,
|
||||||
|
});
|
||||||
|
SwitchboardStatus.Text = "게이트웨이 기동 중… 잠시 후 테스트를 누르세요.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SwitchboardStatus.Text = "런처 스크립트를 찾을 수 없습니다 (start-gateway-lan.cmd).";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SwitchboardStatus.Text = "기동 실패: " + Trunc(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnVerifyAgy(object sender, RoutedEventArgs e)
|
||||||
|
=> await VerifyAsync(AgyDot, AgyStatus, AgyVerifyBtn, new AgyChatClient(), ModelOr(string.Empty));
|
||||||
|
|
||||||
private async void OnVerifyCodex(object sender, RoutedEventArgs e)
|
private async void OnVerifyCodex(object sender, RoutedEventArgs e)
|
||||||
=> await VerifyAsync(CodexDot, CodexStatus, CodexVerifyBtn, new CodexChatClient(), ModelOr(string.Empty));
|
=> await VerifyAsync(CodexDot, CodexStatus, CodexVerifyBtn, new CodexChatClient(), ModelOr(string.Empty));
|
||||||
|
|
||||||
|
|
@ -107,23 +170,59 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
LibreDot.Fill = Res(libre ? "FsStatusSuccess" : "FsStatusWarn");
|
LibreDot.Fill = Res(libre ? "FsStatusSuccess" : "FsStatusWarn");
|
||||||
LibreStatus.Text = libre ? "준비됨" : "미설치";
|
LibreStatus.Text = libre ? "준비됨" : "미설치";
|
||||||
|
|
||||||
|
_ = CheckSwitchboardGatewayAsync();
|
||||||
|
|
||||||
|
var agy = ExternalToolDetector.IsAgyAvailable(out _);
|
||||||
|
AgyDot.Fill = Res(agy ? "FsStatusSuccess" : "FsTextTertiary");
|
||||||
|
AgyStatus.Text = agy ? "설치됨 — 키 없이 사용 가능" : "미설치 (winget install Google.AntigravityCLI)";
|
||||||
|
AgyVerifyBtn.IsEnabled = agy;
|
||||||
|
|
||||||
var codex = ExternalToolDetector.IsCodexAvailable();
|
var codex = ExternalToolDetector.IsCodexAvailable();
|
||||||
CodexDot.Fill = Res(codex ? "FsStatusSuccess" : "FsTextTertiary");
|
CodexDot.Fill = Res(codex ? "FsStatusSuccess" : "FsTextTertiary");
|
||||||
CodexStatus.Text = codex ? "설치됨 — 키 없이 사용 가능" : "미설치 (npm i -g @openai/codex)";
|
CodexStatus.Text = codex ? "설치됨 — 키 없이 사용 가능" : "미설치 (npm i -g @openai/codex)";
|
||||||
CodexVerifyBtn.IsEnabled = codex;
|
CodexVerifyBtn.IsEnabled = codex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task CheckSwitchboardGatewayAsync()
|
||||||
|
{
|
||||||
|
if (SwitchboardDot is null || SwitchboardStatus is null) return;
|
||||||
|
var ep = string.IsNullOrWhiteSpace(SwitchboardEndpointBox?.Text)
|
||||||
|
? (_settings.Get("switchboard.endpoint") ?? "http://127.0.0.1:8787")
|
||||||
|
: SwitchboardEndpointBox.Text.Trim();
|
||||||
|
var (ok, profile, resolvedEp) = await ExternalToolDetector.CheckSwitchboardGatewayHealthAsync(ep);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
SwitchboardDot.Fill = Res("FsStatusSuccess");
|
||||||
|
SwitchboardStatus.Text = string.IsNullOrWhiteSpace(profile)
|
||||||
|
? $"실행 중 ({resolvedEp})"
|
||||||
|
: $"실행 중 ({resolvedEp} · {profile})";
|
||||||
|
if (SwitchboardStartBtn is not null) SwitchboardStartBtn.IsEnabled = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SwitchboardDot.Fill = Res("FsTextTertiary");
|
||||||
|
SwitchboardStatus.Text = $"미실행 ({ep})";
|
||||||
|
if (SwitchboardStartBtn is not null) SwitchboardStartBtn.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnSave(object sender, RoutedEventArgs e)
|
private void OnSave(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var backend = BackendCombo.SelectedIndex switch
|
var backend = BackendCombo.SelectedIndex switch
|
||||||
{
|
{
|
||||||
1 => "openai",
|
1 => "switchboard",
|
||||||
2 => "anthropic",
|
2 => "agy",
|
||||||
3 => "codex",
|
3 => "openai",
|
||||||
|
4 => "anthropic",
|
||||||
|
5 => "codex",
|
||||||
_ => "auto",
|
_ => "auto",
|
||||||
};
|
};
|
||||||
_settings.Set("ai.backend", backend);
|
_settings.Set("ai.backend", backend);
|
||||||
|
|
||||||
|
var sbEndpoint = SwitchboardEndpointBox.Text?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(sbEndpoint))
|
||||||
|
_settings.Set("switchboard.endpoint", sbEndpoint);
|
||||||
|
|
||||||
var model = ModelBox.Text?.Trim();
|
var model = ModelBox.Text?.Trim();
|
||||||
if (string.IsNullOrEmpty(model)) _settings.Remove("ai.model");
|
if (string.IsNullOrEmpty(model)) _settings.Remove("ai.model");
|
||||||
else _settings.Set("ai.model", model);
|
else _settings.Set("ai.model", model);
|
||||||
|
|
@ -137,6 +236,13 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
|
|
||||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private void OnOpenToolInstaller(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var win = new ToolSetupWindow { Owner = this };
|
||||||
|
win.ShowDialog();
|
||||||
|
RefreshToolStatus();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDownloadFfmpeg(object sender, RoutedEventArgs e)
|
private void OnDownloadFfmpeg(object sender, RoutedEventArgs e)
|
||||||
=> OpenUrl("https://github.com/BtbN/FFmpeg-Builds/releases");
|
=> OpenUrl("https://github.com/BtbN/FFmpeg-Builds/releases");
|
||||||
|
|
||||||
|
|
|
||||||
88
src/Everything2Everything.App/Views/ToolSetupWindow.xaml
Normal file
88
src/Everything2Everything.App/Views/ToolSetupWindow.xaml
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<ui:FluentWindow x:Class="Everything2Everything.App.Views.ToolSetupWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
Title="처음 설정 — 외부 도구 설치"
|
||||||
|
Width="640" Height="680"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ExtendsContentIntoTitleBar="True"
|
||||||
|
WindowBackdropType="Mica"
|
||||||
|
Background="{DynamicResource FsBgBase}">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ui:ControlsDictionary/>
|
||||||
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid Background="{StaticResource FsBgBase}">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ui:TitleBar Grid.Row="0" Title="처음 설정" ShowMaximize="False" ShowMinimize="False"/>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="24,8,24,16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="필요한 외부 도구를 설치합니다."
|
||||||
|
Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="16"/>
|
||||||
|
<TextBlock Text="변환에 필요한 외부 도구 목록입니다. 기본값은 모두 선택된 상태이며, 아래 버튼으로 한 번에 순차 설치합니다. 이미 설치된 도구는 설치 단계에서 확인만 됩니다. (H2Orestart는 라이선스상 자동 설치 대신 직접 추가가 필요합니다.)"
|
||||||
|
Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,16"/>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Tools}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Style="{StaticResource FsDoubleBezelShellStyle}" Margin="0,0,0,10" HorizontalAlignment="Stretch">
|
||||||
|
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="16,12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||||
|
<StackPanel Grid.Column="1" Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="{Binding Name}" Style="{StaticResource FsBodyStyle}" FontWeight="Medium"/>
|
||||||
|
<TextBlock Text="{Binding Description}" Style="{StaticResource FsCaptionStyle}"
|
||||||
|
TextWrapping="Wrap" Margin="0,3,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Ellipse Width="9" Height="9" Style="{StaticResource FsLossDotStyle}"
|
||||||
|
Fill="{Binding StatusBrush}" VerticalAlignment="Center" Margin="0,0,7,0"/>
|
||||||
|
<TextBlock Text="{Binding StatusText}" Style="{StaticResource FsCaptionStyle}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Border Grid.Row="2" Background="{StaticResource FsBgPanel}"
|
||||||
|
BorderBrush="{StaticResource FsBorderSubtle}" BorderThickness="0,1,0,0" Padding="24,14">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding SelectAll}" Content="전체 선택"
|
||||||
|
Foreground="{StaticResource FsTextSecondary}" VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Content="나중에" Style="{StaticResource FsSecondaryButtonStyle}" Padding="20,8" Click="OnLater"/>
|
||||||
|
<Button x:Name="InstallButton" Content="설치 시작"
|
||||||
|
Style="{StaticResource FsPrimaryButtonStyle}" Padding="20,8" Margin="8,0,0,0" Click="OnInstall"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</ui:FluentWindow>
|
||||||
48
src/Everything2Everything.App/Views/ToolSetupWindow.xaml.cs
Normal file
48
src/Everything2Everything.App/Views/ToolSetupWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows;
|
||||||
|
using Everything2Everything.App.ViewModels;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.Views;
|
||||||
|
|
||||||
|
/// <summary>첫 실행 외부 도구 설치 마법사. 선택된(기본 전체) 도구를 순차 설치하고 상태를 갱신한다.</summary>
|
||||||
|
public partial class ToolSetupWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
|
{
|
||||||
|
private readonly ToolSetupViewModel _vm;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
public ToolSetupWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_vm = new ToolSetupViewModel();
|
||||||
|
DataContext = _vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnInstall(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_vm.IsInstalling) return;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
InstallButton.IsEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _vm.InstallSelectedAsync(_cts.Token);
|
||||||
|
// 온보딩 완료 → 다이얼로그를 닫아 ShowMainWindow가 뒤이어 메인 창을 띄우게 한다.
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
InstallButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLater(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
base.OnClosed(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Everything2Everything.Core/Ads/AdItem.cs
Normal file
21
src/Everything2Everything.Core/Ads/AdItem.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace Everything2Everything.Core.Ads;
|
||||||
|
|
||||||
|
public enum AdType
|
||||||
|
{
|
||||||
|
Banner,
|
||||||
|
LargeCard
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AdItem
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string BadgeText { get; set; } = "AD";
|
||||||
|
public string ImageUrl { get; set; } = string.Empty;
|
||||||
|
public string TargetUrl { get; set; } = string.Empty;
|
||||||
|
public string CtaText { get; set; } = "자세히 보기";
|
||||||
|
public AdType Type { get; set; } = AdType.Banner;
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public int DisplayOrder { get; set; } = 0;
|
||||||
|
}
|
||||||
34
src/Everything2Everything.Core/Ads/AdMobConfig.cs
Normal file
34
src/Everything2Everything.Core/Ads/AdMobConfig.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
namespace Everything2Everything.Core.Ads;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Google AdMob 공식 앱 ID, 광고 단위 ID 및 app-ads.txt 검증 구성 모델
|
||||||
|
/// </summary>
|
||||||
|
public class AdMobConfig
|
||||||
|
{
|
||||||
|
/// <summary>Google AdMob 게시자 ID (예: pub-XXXXXXXXXXXXXXXX)</summary>
|
||||||
|
public string PublisherId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>AdMob 공식 앱 ID (형식: ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY)</summary>
|
||||||
|
public string AppId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>배너 광고 단위 ID (형식: ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY)</summary>
|
||||||
|
public string BannerAdUnitId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>전면/대형 카드 광고 단위 ID (형식: ca-app-pub-XXXXXXXXXXXXXXXX/ZZZZZZZZZZ)</summary>
|
||||||
|
public string InterstitialAdUnitId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Google 공식 규격의 app-ads.txt 문자열 생성
|
||||||
|
/// 형식: google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0
|
||||||
|
/// </summary>
|
||||||
|
public string GenerateAppAdsTxt()
|
||||||
|
{
|
||||||
|
var pubId = PublisherId.Trim();
|
||||||
|
if (!pubId.StartsWith("pub-", System.StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(pubId))
|
||||||
|
{
|
||||||
|
pubId = "pub-" + pubId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"google.com, {pubId}, DIRECT, f08c47fec0942fa0";
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/Everything2Everything.Core/Ads/AdService.cs
Normal file
221
src/Everything2Everything.Core/Ads/AdService.cs
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Core.Ads;
|
||||||
|
|
||||||
|
public class AdService : IAdService
|
||||||
|
{
|
||||||
|
private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(5) };
|
||||||
|
private readonly List<AdItem> _ads = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private int _bannerIndex = 0;
|
||||||
|
private int _largeCardIndex = 0;
|
||||||
|
|
||||||
|
public AdService()
|
||||||
|
{
|
||||||
|
InitializeDefaultAds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeDefaultAds()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_ads.Clear();
|
||||||
|
|
||||||
|
// Default House Banner Ads
|
||||||
|
_ads.Add(new AdItem
|
||||||
|
{
|
||||||
|
Id = "house-banner-sponsor-1",
|
||||||
|
Title = "초고속 무손실 미디어 솔루션",
|
||||||
|
Description = "Everything2Everything 공식 후원 및 제휴 파트너십을 확인하세요.",
|
||||||
|
BadgeText = "스폰서",
|
||||||
|
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/logo-mark.png",
|
||||||
|
TargetUrl = "https://git.chanpaca.net/yunchan/Everything2Everything",
|
||||||
|
CtaText = "자세히 보기",
|
||||||
|
Type = AdType.Banner,
|
||||||
|
IsActive = true,
|
||||||
|
DisplayOrder = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
_ads.Add(new AdItem
|
||||||
|
{
|
||||||
|
Id = "house-banner-tip-2",
|
||||||
|
Title = "대용량 일괄 변환 최적화",
|
||||||
|
Description = "FFmpeg 및 LibRaw 가속 엔진으로 수천 장의 이미지를 순식간에 처리합니다.",
|
||||||
|
BadgeText = "AD",
|
||||||
|
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/glyph-video.png",
|
||||||
|
TargetUrl = "https://git.chanpaca.net/yunchan/Everything2Everything#features",
|
||||||
|
CtaText = "가이드 보기",
|
||||||
|
Type = AdType.Banner,
|
||||||
|
IsActive = true,
|
||||||
|
DisplayOrder = 2
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default Large Card Ads
|
||||||
|
_ads.Add(new AdItem
|
||||||
|
{
|
||||||
|
Id = "house-large-card-1",
|
||||||
|
Title = "비즈니스 미디어 변환 엔진",
|
||||||
|
Description = "개인 및 기업을 위한 고성능 무손실 일괄 변환. 워터마크 없이 100% 무료로 자유롭게 활용하세요.",
|
||||||
|
BadgeText = "스폰서 추천",
|
||||||
|
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/illus-done.png",
|
||||||
|
TargetUrl = "https://git.chanpaca.net/yunchan/Everything2Everything",
|
||||||
|
CtaText = "파트너십 문의",
|
||||||
|
Type = AdType.LargeCard,
|
||||||
|
IsActive = true,
|
||||||
|
DisplayOrder = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
_ads.Add(new AdItem
|
||||||
|
{
|
||||||
|
Id = "house-large-card-2",
|
||||||
|
Title = "스마트 AI 요약 & OCR 가속",
|
||||||
|
Description = "로컬 LLM과 Tesseract OCR을 결합하여 스캔 문서와 PDF를 즉시 검색 가능한 텍스트로 변환합니다.",
|
||||||
|
BadgeText = "AD",
|
||||||
|
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/glyph-ai.png",
|
||||||
|
TargetUrl = "https://git.chanpaca.net/yunchan/Everything2Everything",
|
||||||
|
CtaText = "기능 살펴보기",
|
||||||
|
Type = AdType.LargeCard,
|
||||||
|
IsActive = true,
|
||||||
|
DisplayOrder = 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<AdItem> GetBannerAds()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _ads.Where(a => a.Type == AdType.Banner && a.IsActive).OrderBy(a => a.DisplayOrder).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<AdItem> GetLargeCardAds()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _ads.Where(a => a.Type == AdType.LargeCard && a.IsActive).OrderBy(a => a.DisplayOrder).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdItem? GetNextBannerAd()
|
||||||
|
{
|
||||||
|
var banners = GetBannerAds();
|
||||||
|
if (banners.Count == 0) return null;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var ad = banners[_bannerIndex % banners.Count];
|
||||||
|
_bannerIndex = (_bannerIndex + 1) % banners.Count;
|
||||||
|
return ad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdItem? GetNextLargeCardAd()
|
||||||
|
{
|
||||||
|
var cards = GetLargeCardAds();
|
||||||
|
if (cards.Count == 0) return null;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var ad = cards[_largeCardIndex % cards.Count];
|
||||||
|
_largeCardIndex = (_largeCardIndex + 1) % cards.Count;
|
||||||
|
return ad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int LoadAdsFromJson(string json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
|
var items = JsonSerializer.Deserialize<List<AdItem>>(json, options);
|
||||||
|
if (items == null || items.Count == 0) return 0;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.Id))
|
||||||
|
{
|
||||||
|
item.Id = Guid.NewGuid().ToString("N");
|
||||||
|
}
|
||||||
|
var existingIdx = _ads.FindIndex(a => a.Id == item.Id);
|
||||||
|
if (existingIdx >= 0)
|
||||||
|
{
|
||||||
|
_ads[existingIdx] = item;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_ads.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.Count;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> RefreshAdsFromFeedAsync(string feedUrl, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(feedUrl) || !Uri.TryCreate(feedUrl, UriKind.Absolute, out var uri) ||
|
||||||
|
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await HttpClient.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||||
|
return LoadAdsFromJson(response);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool OpenAdUrl(AdItem ad)
|
||||||
|
{
|
||||||
|
if (ad == null || string.IsNullOrWhiteSpace(ad.TargetUrl))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(ad.TargetUrl, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = uri.AbsoluteUri,
|
||||||
|
UseShellExecute = true
|
||||||
|
};
|
||||||
|
Process.Start(psi);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/Everything2Everything.Core/Ads/IAdService.cs
Normal file
16
src/Everything2Everything.Core/Ads/IAdService.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Core.Ads;
|
||||||
|
|
||||||
|
public interface IAdService
|
||||||
|
{
|
||||||
|
IReadOnlyList<AdItem> GetBannerAds();
|
||||||
|
IReadOnlyList<AdItem> GetLargeCardAds();
|
||||||
|
AdItem? GetNextBannerAd();
|
||||||
|
AdItem? GetNextLargeCardAd();
|
||||||
|
Task<int> RefreshAdsFromFeedAsync(string feedUrl, CancellationToken cancellationToken = default);
|
||||||
|
int LoadAdsFromJson(string json);
|
||||||
|
bool OpenAdUrl(AdItem ad);
|
||||||
|
}
|
||||||
|
|
@ -137,3 +137,108 @@ public sealed class CodexChatClient : IChatClient
|
||||||
|
|
||||||
private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
|
private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class AgyChatClient : IChatClient
|
||||||
|
{
|
||||||
|
private readonly string _agyPath;
|
||||||
|
|
||||||
|
public AgyChatClient(string? agyPath = null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(agyPath))
|
||||||
|
{
|
||||||
|
_agyPath = agyPath;
|
||||||
|
}
|
||||||
|
else if (ExternalToolDetector.IsAgyAvailable(out var detected))
|
||||||
|
{
|
||||||
|
_agyPath = detected;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_agyPath = "agy";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "Antigravity CLI (agy)";
|
||||||
|
|
||||||
|
public async Task<string> CompleteAsync(string systemPrompt, string userPrompt, string model, int maxTokens, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var prompt = string.IsNullOrWhiteSpace(systemPrompt) ? userPrompt : systemPrompt + "\n\n---\n\n" + userPrompt;
|
||||||
|
|
||||||
|
var args = new List<string>
|
||||||
|
{
|
||||||
|
"-p", prompt,
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"--disable-slash-commands",
|
||||||
|
"--output-format", "text"
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(model))
|
||||||
|
{
|
||||||
|
args.Add("--model");
|
||||||
|
args.Add(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = await ExternalProcessRunner.RunAsync(
|
||||||
|
_agyPath, args, TimeSpan.FromMinutes(5), workingDirectory: null,
|
||||||
|
cancellationToken: ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (r.TimedOut)
|
||||||
|
throw new InvalidOperationException("Antigravity CLI 응답이 시간 초과되었습니다 (5분).");
|
||||||
|
|
||||||
|
if (!r.Success)
|
||||||
|
{
|
||||||
|
var detail = !string.IsNullOrWhiteSpace(r.StdErr) ? r.StdErr : r.StdOut;
|
||||||
|
throw new InvalidOperationException($"Antigravity CLI 오류 (exit {r.ExitCode}): {Truncate(detail)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.StdOut.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 로컬 또는 네트워크에 기동된 Switchboard Gateway (http://127.0.0.1:8787)를 호출하는 백엔드.
|
||||||
|
/// 게이트웨이의 /chat 엔드포인트와 통신하며, 게이트웨이가 관리하는 에이전트(agy, codex 등)를 통해 텍스트를 완성한다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SwitchboardChatClient : IChatClient
|
||||||
|
{
|
||||||
|
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(5) };
|
||||||
|
private readonly string _endpoint;
|
||||||
|
|
||||||
|
public SwitchboardChatClient(string? endpoint = null)
|
||||||
|
{
|
||||||
|
var raw = string.IsNullOrWhiteSpace(endpoint) ? "http://127.0.0.1:8787" : endpoint.Trim();
|
||||||
|
_endpoint = raw.TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "Switchboard Gateway";
|
||||||
|
public string Endpoint => _endpoint;
|
||||||
|
|
||||||
|
public async Task<string> CompleteAsync(string systemPrompt, string userPrompt, string model, int maxTokens, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var message = string.IsNullOrWhiteSpace(systemPrompt) ? userPrompt : systemPrompt + "\n\n---\n\n" + userPrompt;
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
message,
|
||||||
|
sessionId = "e2e-" + Guid.NewGuid().ToString("N")[..8],
|
||||||
|
model = string.IsNullOrWhiteSpace(model) ? null : model,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, $"{_endpoint}/chat");
|
||||||
|
req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
using var resp = await Http.SendAsync(req, ct).ConfigureAwait(false);
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
throw new InvalidOperationException($"Switchboard 게이트웨이 오류 ({(int)resp.StatusCode}): {Truncate(json)}");
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.TryGetProperty("reply", out var replyProp))
|
||||||
|
return replyProp.GetString() ?? "";
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Switchboard 게이트웨이 응답에 'reply' 필드가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
namespace Everything2Everything.Core.Converters;
|
||||||
|
|
||||||
|
/// <summary>설치 방식 분류. Winget = 자동 설치 가능, Manual = 사용자 안내만(라이선스/URL 제약).</summary>
|
||||||
|
public enum ExternalToolInstallKind
|
||||||
|
{
|
||||||
|
Winget,
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>첫 실행 설치 마법사가 다루는 외부 도구 한 항목의 정의. 감지 함수는 설치 여부 판정을 담당한다.</summary>
|
||||||
|
public sealed class ExternalToolDefinition
|
||||||
|
{
|
||||||
|
public required string Key { get; init; }
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
public required string Description { get; init; }
|
||||||
|
public required ExternalToolInstallKind Kind { get; init; }
|
||||||
|
public string? WingetId { get; init; }
|
||||||
|
public string? ManualNote { get; init; }
|
||||||
|
public required Func<bool> IsInstalled { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 앱이 의존하는 외부 도구의 정적 카탈로그. winget 패키지 ID는 기기에서 실검증된 값이다.
|
||||||
|
/// (Gyan.FFmpeg / TheDocumentFoundation.LibreOffice / JohnMacFarlane.Pandoc /
|
||||||
|
/// ImageMagick.ImageMagick / Google.AntigravityCLI / OpenAI.Codex)
|
||||||
|
/// H2Orestart는 GPL이고 프로젝트 원칙(번들 금지·외부 조달)상 자동 설치하지 않고 안내만 한다.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExternalToolCatalog
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<ExternalToolDefinition> All { get; } = new[]
|
||||||
|
{
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "ffmpeg",
|
||||||
|
DisplayName = "FFmpeg",
|
||||||
|
Description = "영상/오디오 변환 (mp4·webm·mp3·flac…). ffmpeg·ffprobe 포함.",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "Gyan.FFmpeg",
|
||||||
|
IsInstalled = () => ExternalToolDetector.TryFindFfmpeg(out _),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "libreoffice",
|
||||||
|
DisplayName = "LibreOffice",
|
||||||
|
Description = "한글/Word/문서 변환 (DOCX→PDF·이미지).",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "TheDocumentFoundation.LibreOffice",
|
||||||
|
IsInstalled = () => ExternalToolDetector.TryFindLibreOfficeSoffice(out _),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "pandoc",
|
||||||
|
DisplayName = "Pandoc",
|
||||||
|
Description = "마크업 변환 (md·rst·latex·epub…).",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "JohnMacFarlane.Pandoc",
|
||||||
|
IsInstalled = () => ExternalToolDetector.TryFindPandoc(out _),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "imagemagick",
|
||||||
|
DisplayName = "ImageMagick",
|
||||||
|
Description = "HEIC 등 이미지 처리 변환.",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "ImageMagick.ImageMagick",
|
||||||
|
IsInstalled = () => ExternalToolDetector.TryFindMagick(out _),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "h2orestart",
|
||||||
|
DisplayName = "H2Orestart (한글 확장)",
|
||||||
|
Description = "LibreOffice에서 .hwp/.hwpx 열기용 UNO 확장.",
|
||||||
|
Kind = ExternalToolInstallKind.Manual,
|
||||||
|
ManualNote = "LibreOffice 설치 후 확장 관리자(Tools → Extension Manager)에서 H2Orestart.oxt를 직접 추가하세요.",
|
||||||
|
IsInstalled = () => ExternalToolDetector.IsH2OrestartInstalled(),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "agy",
|
||||||
|
DisplayName = "Antigravity CLI (agy)",
|
||||||
|
Description = "AI 변환 백엔드 (Google OAuth · API 키 불필요).",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "Google.AntigravityCLI",
|
||||||
|
IsInstalled = () => ExternalToolDetector.IsAgyAvailable(out _),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "codex",
|
||||||
|
DisplayName = "Codex CLI",
|
||||||
|
Description = "AI 변환 백엔드 (ChatGPT 구독 OAuth).",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "OpenAI.Codex",
|
||||||
|
IsInstalled = () => ExternalToolDetector.IsCodexAvailable(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ExternalToolDefinition? Find(string key)
|
||||||
|
=> All.FirstOrDefault(t => string.Equals(t.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,22 @@ public static class ExternalToolDetector
|
||||||
if (!string.IsNullOrWhiteSpace(dir))
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
candidates.Add(dir.Trim());
|
candidates.Add(dir.Trim());
|
||||||
|
|
||||||
|
// winget(Gyan.FFmpeg)은 zip 해제본을 %LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin에 둔다.
|
||||||
|
// PATH에 없으므로 설치 후에도 감지되도록 패키지 폴더를 직접 스캔한다.
|
||||||
|
var winGetPackages = Path.Combine(local, "Microsoft", "WinGet", "Packages");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(winGetPackages))
|
||||||
|
{
|
||||||
|
foreach (var pkgDir in Directory.EnumerateDirectories(winGetPackages, "Gyan.FFmpeg*"))
|
||||||
|
{
|
||||||
|
foreach (var bin in Directory.EnumerateDirectories(pkgDir, "bin", SearchOption.AllDirectories))
|
||||||
|
candidates.Add(bin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* WinGet 패키지 디렉터리 스캔 실패 무시 */ }
|
||||||
|
|
||||||
foreach (var dir in candidates.Distinct())
|
foreach (var dir in candidates.Distinct())
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -82,6 +98,78 @@ public static class ExternalToolDetector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pandoc 바이너리를 찾는다. (1) %LOCALAPPDATA%\Pandoc (winget 기본 설치 위치),
|
||||||
|
/// (2) WinGet Links, (3) 시스템 PATH 순.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryFindPandoc(out string pandocPath)
|
||||||
|
{
|
||||||
|
pandocPath = "";
|
||||||
|
var candidates = new List<string>();
|
||||||
|
|
||||||
|
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (!string.IsNullOrEmpty(local))
|
||||||
|
{
|
||||||
|
candidates.Add(Path.Combine(local, "Pandoc", "pandoc.exe"));
|
||||||
|
candidates.Add(Path.Combine(local, "Microsoft", "WinGet", "Links", "pandoc.exe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
|
foreach (var dir in pathEnv.Split(Path.PathSeparator))
|
||||||
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
|
candidates.Add(Path.Combine(dir.Trim(), "pandoc.exe"));
|
||||||
|
|
||||||
|
foreach (var p in candidates.Distinct())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(p)) { pandocPath = p; return true; }
|
||||||
|
}
|
||||||
|
catch { /* 잘못된 경로 무시 */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ImageMagick(magick.exe) 바이너리를 찾는다. (1) %ProgramFiles%/ProgramFiles(x86) 아래
|
||||||
|
/// ImageMagick-* 폴더, (2) 실제 PATH 순.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryFindMagick(out string magickPath)
|
||||||
|
{
|
||||||
|
magickPath = "";
|
||||||
|
var candidates = new List<string>();
|
||||||
|
|
||||||
|
foreach (var root in new[]
|
||||||
|
{
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(root)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var dir in Directory.EnumerateDirectories(root, "ImageMagick-*"))
|
||||||
|
candidates.Add(Path.Combine(dir, "magick.exe"));
|
||||||
|
}
|
||||||
|
catch { /* 디렉터리 열람 실패 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
|
foreach (var dir in pathEnv.Split(Path.PathSeparator))
|
||||||
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
|
candidates.Add(Path.Combine(dir.Trim(), "magick.exe"));
|
||||||
|
|
||||||
|
foreach (var p in candidates.Distinct())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(p)) { magickPath = p; return true; }
|
||||||
|
}
|
||||||
|
catch { /* 잘못된 경로 무시 */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// codex CLI(OpenAI Codex, ChatGPT 구독 OAuth 재사용) 설치 여부. npm 글로벌 + PATH에서
|
/// codex CLI(OpenAI Codex, ChatGPT 구독 OAuth 재사용) 설치 여부. npm 글로벌 + PATH에서
|
||||||
/// codex.cmd/codex.exe/codex.ps1을 탐지한다.
|
/// codex.cmd/codex.exe/codex.ps1을 탐지한다.
|
||||||
|
|
@ -110,6 +198,38 @@ public static class ExternalToolDetector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Antigravity CLI (agy, Google 개인 OAuth 재사용) 설치 여부. WinGet Links 및 PATH에서 agy.exe를 탐지한다.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsAgyAvailable(out string agyPath)
|
||||||
|
{
|
||||||
|
agyPath = "";
|
||||||
|
var candidates = new List<string>();
|
||||||
|
|
||||||
|
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (!string.IsNullOrEmpty(local))
|
||||||
|
candidates.Add(Path.Combine(local, "Microsoft", "WinGet", "Links", "agy.exe"));
|
||||||
|
|
||||||
|
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
|
foreach (var d in pathEnv.Split(Path.PathSeparator))
|
||||||
|
if (!string.IsNullOrWhiteSpace(d))
|
||||||
|
candidates.Add(Path.Combine(d.Trim(), "agy.exe"));
|
||||||
|
|
||||||
|
foreach (var p in candidates.Distinct())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(p))
|
||||||
|
{
|
||||||
|
agyPath = p;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* 잘못된 경로 무시 */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsH2OrestartInstalled()
|
public static bool IsH2OrestartInstalled()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -143,4 +263,56 @@ public static class ExternalToolDetector
|
||||||
catch { }
|
catch { }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 로컬 루프백 또는 지정된 LAN의 Switchboard Gateway 청취 여부를 100ms 이내에 신속히 감지한다.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsSwitchboardGatewayAvailable(out string endpoint, string? configuredEndpoint = null)
|
||||||
|
{
|
||||||
|
endpoint = string.IsNullOrWhiteSpace(configuredEndpoint) ? "http://127.0.0.1:8787" : configuredEndpoint.Trim().TrimEnd('/');
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(endpoint);
|
||||||
|
using var tcp = new System.Net.Sockets.TcpClient();
|
||||||
|
var host = uri.Host;
|
||||||
|
var port = uri.Port > 0 ? uri.Port : 8787;
|
||||||
|
var result = tcp.BeginConnect(host, port, null, null);
|
||||||
|
var success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromMilliseconds(100));
|
||||||
|
if (!success || !tcp.Connected) return false;
|
||||||
|
tcp.EndConnect(result);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switchboard Gateway (기본 http://127.0.0.1:8787) 헬스체크 및 에이전트 프로필을 비동기로 정밀 검증한다.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<(bool available, string? agentProfile, string endpoint)> CheckSwitchboardGatewayHealthAsync(
|
||||||
|
string? endpoint = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ep = string.IsNullOrWhiteSpace(endpoint) ? "http://127.0.0.1:8787" : endpoint.Trim().TrimEnd('/');
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
cts.CancelAfter(TimeSpan.FromMilliseconds(1500));
|
||||||
|
using var client = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromMilliseconds(1500) };
|
||||||
|
using var resp = await client.GetAsync($"{ep}/health", cts.Token).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode) return (false, null, ep);
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var ok = root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean();
|
||||||
|
var profile = root.TryGetProperty("agentProfile", out var pProp) ? pProp.GetString() : null;
|
||||||
|
return (ok, profile, ep);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return (false, null, ep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Core.Converters;
|
||||||
|
|
||||||
|
/// <summary>외부 명령 한 번을 실행하는 시드. 프로세스 실행을 추상화해 설치 로직을 단위 테스트 가능하게 한다.</summary>
|
||||||
|
public interface IExternalCommandRunner
|
||||||
|
{
|
||||||
|
Task<ExternalCommandResult> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExternalCommandResult(int ExitCode, string Output);
|
||||||
|
|
||||||
|
/// <summary>System.Diagnostics.Process 기반 기본 구현. 숨김 실행 + 출력(꼬리) 캡처 + 취소 시 Kill.</summary>
|
||||||
|
public sealed class SystemCommandRunner : IExternalCommandRunner
|
||||||
|
{
|
||||||
|
public async Task<ExternalCommandResult> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = fileName,
|
||||||
|
Arguments = arguments,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
WorkingDirectory = workingDirectory ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
};
|
||||||
|
|
||||||
|
using var proc = new Process { StartInfo = psi };
|
||||||
|
var outTail = new StringBuilder();
|
||||||
|
var errTail = new StringBuilder();
|
||||||
|
proc.OutputDataReceived += (_, e) => { if (e.Data is not null) AppendCapped(outTail, e.Data); };
|
||||||
|
proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) AppendCapped(errTail, e.Data); };
|
||||||
|
|
||||||
|
proc.Start();
|
||||||
|
proc.BeginOutputReadLine();
|
||||||
|
proc.BeginErrorReadLine();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await proc.WaitForExitAsync(ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Kill(proc);
|
||||||
|
return new ExternalCommandResult(-1, "취소됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
var joined = (outTail + "\n" + errTail).Trim();
|
||||||
|
return new ExternalCommandResult(proc.ExitCode, joined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Kill(Process proc)
|
||||||
|
{
|
||||||
|
try { proc.Kill(entireProcessTree: true); }
|
||||||
|
catch { }
|
||||||
|
try { proc.WaitForExit(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>출력이 폭주하지 않도록 꼬리 일부만 유지한다.</summary>
|
||||||
|
private static void AppendCapped(StringBuilder sb, string line, int max = 4000)
|
||||||
|
{
|
||||||
|
lock (sb)
|
||||||
|
{
|
||||||
|
if (sb.Length > max * 3) return;
|
||||||
|
sb.AppendLine(line);
|
||||||
|
if (sb.Length > max)
|
||||||
|
{
|
||||||
|
var keep = sb.ToString(sb.Length - max, max);
|
||||||
|
sb.Clear();
|
||||||
|
sb.Append('…');
|
||||||
|
sb.Append(keep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExternalToolInstallResult(bool Success, int ExitCode, string Output, string? Message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 외부 도구 설치 실행기. Winget 도구는 winget 무인 설치 명령을 만들고 실행한 뒤,
|
||||||
|
/// 설치 여부를 다시 감지(IsInstalled)해 실제 성공 여부를 판정한다.
|
||||||
|
/// Manual(수동) 도구는 실행하지 않고 안내 문구만 돌려준다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExternalToolInstaller
|
||||||
|
{
|
||||||
|
private readonly IExternalCommandRunner _runner;
|
||||||
|
|
||||||
|
public ExternalToolInstaller(IExternalCommandRunner? runner = null)
|
||||||
|
=> _runner = runner ?? new SystemCommandRunner();
|
||||||
|
|
||||||
|
public static bool IsWingetAvailable()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo("winget", "--version")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
};
|
||||||
|
using var p = Process.Start(psi);
|
||||||
|
if (p is null) return false;
|
||||||
|
if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; }
|
||||||
|
return p.ExitCode == 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExternalToolInstallResult> InstallAsync(ExternalToolDefinition tool, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 수동 도구(H2Orestart 등)는 자동 설치를 시도하지 않고 안내문을 돌려준다.
|
||||||
|
if (tool.Kind != ExternalToolInstallKind.Winget || string.IsNullOrWhiteSpace(tool.WingetId))
|
||||||
|
return new ExternalToolInstallResult(tool.IsInstalled(), 0, "", tool.ManualNote);
|
||||||
|
|
||||||
|
var args = $"install --id {tool.WingetId} -e --source winget --silent " +
|
||||||
|
"--accept-package-agreements --accept-source-agreements --disable-interactivity";
|
||||||
|
|
||||||
|
ExternalCommandResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await _runner.RunAsync("winget", args, null, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new ExternalToolInstallResult(false, -1, ex.Message, "winget 실행 실패 — 설치할 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 판정은 exit code가 아니라 '설치 후 실제 감지'로 한다(앱 입장의 실질 기준).
|
||||||
|
var installed = tool.IsInstalled();
|
||||||
|
return new ExternalToolInstallResult(
|
||||||
|
installed,
|
||||||
|
result.ExitCode,
|
||||||
|
result.Output,
|
||||||
|
installed ? null : $"설치 실패 또는 아직 감지 안 됨 (코드 {result.ExitCode})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -96,16 +96,28 @@ public sealed class LlmProvider : IConverterProvider
|
||||||
return openaiKey is null ? (null, "") : (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini");
|
return openaiKey is null ? (null, "") : (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini");
|
||||||
if (backend == "anthropic")
|
if (backend == "anthropic")
|
||||||
return anthropicKey is null ? (null, "") : (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest");
|
return anthropicKey is null ? (null, "") : (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest");
|
||||||
|
if (backend == "agy")
|
||||||
|
return ExternalToolDetector.IsAgyAvailable(out var agyPath) ? (new AgyChatClient(agyPath), ai.Model ?? "") : (null, "");
|
||||||
|
if (backend == "switchboard" || backend == "gateway")
|
||||||
|
{
|
||||||
|
var ep = _settings.Get("switchboard.endpoint");
|
||||||
|
return (new SwitchboardChatClient(ep), ai.Model ?? "");
|
||||||
|
}
|
||||||
if (backend == "codex")
|
if (backend == "codex")
|
||||||
return ExternalToolDetector.IsCodexAvailable() ? (new CodexChatClient(), ai.Model ?? "") : (null, "");
|
return ExternalToolDetector.IsCodexAvailable() ? (new CodexChatClient(), ai.Model ?? "") : (null, "");
|
||||||
|
|
||||||
// auto: API 키 우선, 없으면 Codex CLI(ChatGPT 구독 OAuth)
|
// auto: API 키 우선, 없으면 Antigravity CLI(Gemini OAuth) 또는 Switchboard Gateway 또는 Codex CLI(ChatGPT 구독 OAuth)
|
||||||
if (openaiKey is not null) return (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini");
|
if (openaiKey is not null) return (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini");
|
||||||
if (anthropicKey is not null) return (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest");
|
if (anthropicKey is not null) return (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest");
|
||||||
|
if (ExternalToolDetector.IsAgyAvailable(out var defaultAgyPath)) return (new AgyChatClient(defaultAgyPath), ai.Model ?? "");
|
||||||
|
var sbEp = _settings.Get("switchboard.endpoint");
|
||||||
|
if (ExternalToolDetector.IsSwitchboardGatewayAvailable(out var defaultEp, sbEp)) return (new SwitchboardChatClient(defaultEp), ai.Model ?? "");
|
||||||
if (ExternalToolDetector.IsCodexAvailable()) return (new CodexChatClient(), ai.Model ?? "");
|
if (ExternalToolDetector.IsCodexAvailable()) return (new CodexChatClient(), ai.Model ?? "");
|
||||||
return (null, "");
|
return (null, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal (IChatClient? client, string model) ResolveClientForTesting(AiOptions ai) => ResolveClient(ai);
|
||||||
|
|
||||||
private string? GetKey(string provider)
|
private string? GetKey(string provider)
|
||||||
{
|
{
|
||||||
var stored = _settings.Get($"{provider}.apikey");
|
var stored = _settings.Get($"{provider}.apikey");
|
||||||
|
|
|
||||||
|
|
@ -68,4 +68,15 @@ public static class QueueFilterMatcher
|
||||||
{
|
{
|
||||||
return System.Linq.Enumerable.Where(source, item => Matches(fileNameSelector(item), query, category));
|
return System.Linq.Enumerable.Where(source, item => Matches(fileNameSelector(item), query, category));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>카테고리를 사용자 친화적인 한국어 라벨로 변환합니다.</summary>
|
||||||
|
public static string ToKoreanLabel(this FilterCategory category) => category switch
|
||||||
|
{
|
||||||
|
FilterCategory.All => "전체",
|
||||||
|
FilterCategory.Image => "이미지",
|
||||||
|
FilterCategory.Document => "문서",
|
||||||
|
FilterCategory.Media => "미디어",
|
||||||
|
FilterCategory.Data => "데이터",
|
||||||
|
_ => "기타",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ public static class FileInspectorBuilder
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
var formattedSize = HumanizeBytes(size);
|
var formattedSize = HumanizeBytes(size);
|
||||||
var meta = $"{ext.TrimStart('.').ToUpperInvariant()} · {category}";
|
var meta = $"{ext.TrimStart('.').ToUpperInvariant()} · {category.ToKoreanLabel()}";
|
||||||
|
|
||||||
return new FileInspectorInfo(
|
return new FileInspectorInfo(
|
||||||
FileName: fileName,
|
FileName: fileName,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ public static class FormatPresetEngine
|
||||||
".avif" => AvifPresets(),
|
".avif" => AvifPresets(),
|
||||||
".jpg" or ".jpeg" => JpgPresets(),
|
".jpg" or ".jpeg" => JpgPresets(),
|
||||||
".png" => PngPresets(),
|
".png" => PngPresets(),
|
||||||
|
".gif" => GifPresets(),
|
||||||
|
".heic" => HeicPresets(),
|
||||||
|
|
||||||
".pdf" => PdfPresets(),
|
".pdf" => PdfPresets(),
|
||||||
|
|
||||||
|
|
@ -148,15 +150,15 @@ public static class FormatPresetEngine
|
||||||
new(
|
new(
|
||||||
"webp-web-q85",
|
"webp-web-q85",
|
||||||
"웹 고화질 (Q85 · 추천)",
|
"웹 고화질 (Q85 · 추천)",
|
||||||
"Quality 85 · EXIF 메타데이터 제거 · 웹 게시 표준",
|
"품질 85 · EXIF 메타데이터 제거 · 웹 게시 표준",
|
||||||
new[] { "Quality 85", "Strip EXIF", "웹 최적화" },
|
new[] { "품질 85", "EXIF 제거", "웹 최적화" },
|
||||||
opt => { opt.ImageQuality = 85; opt.StripMetadata = true; }
|
opt => { opt.ImageQuality = 85; opt.StripMetadata = true; }
|
||||||
),
|
),
|
||||||
new(
|
new(
|
||||||
"webp-compact-q65",
|
"webp-compact-q65",
|
||||||
"웹 초경량 (Q65 · 빠른 로딩)",
|
"웹 초경량 (Q65 · 빠른 로딩)",
|
||||||
"Quality 65 · 고압축 이미지로 첫 페이지 로딩 가속",
|
"품질 65 · 고압축 이미지로 첫 페이지 로딩 가속",
|
||||||
new[] { "Quality 65", "Strip EXIF", "초경량" },
|
new[] { "품질 65", "EXIF 제거", "초경량" },
|
||||||
opt => { opt.ImageQuality = 65; opt.StripMetadata = true; }
|
opt => { opt.ImageQuality = 65; opt.StripMetadata = true; }
|
||||||
),
|
),
|
||||||
new(
|
new(
|
||||||
|
|
@ -170,8 +172,15 @@ public static class FormatPresetEngine
|
||||||
"webp-sns-thumb",
|
"webp-sns-thumb",
|
||||||
"SNS 썸네일 (Q75)",
|
"SNS 썸네일 (Q75)",
|
||||||
"피드 및 카드 썸네일 최적화",
|
"피드 및 카드 썸네일 최적화",
|
||||||
new[] { "Quality 75", "썸네일" },
|
new[] { "품질 75", "썸네일" },
|
||||||
opt => { opt.ImageQuality = 75; opt.StripMetadata = true; }
|
opt => { opt.ImageQuality = 75; opt.StripMetadata = true; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"webp-extreme-q50",
|
||||||
|
"초절약 압축 (Q50)",
|
||||||
|
"대역폭 극소화 및 모바일 웹 가속",
|
||||||
|
new[] { "품질 50", "대역폭 절약", "초경량" },
|
||||||
|
opt => { opt.ImageQuality = 50; opt.StripMetadata = true; }
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -179,17 +188,31 @@ public static class FormatPresetEngine
|
||||||
{
|
{
|
||||||
new(
|
new(
|
||||||
"avif-balanced",
|
"avif-balanced",
|
||||||
"차세대 초고압축 (Q55)",
|
"차세대 초고압축 (Q55 · 추천)",
|
||||||
"AV1 코덱 기반 압축률 극대화",
|
"AV1 코덱 기반 압축률 극대화 · 웹 표준",
|
||||||
new[] { "Quality 55", "AV1 코덱", "초고압축" },
|
new[] { "품질 55", "AV1 코덱", "초고압축" },
|
||||||
opt => { opt.ImageQuality = 85; opt.StripMetadata = true; }
|
opt => { opt.ImageQuality = 85; opt.StripMetadata = true; }
|
||||||
),
|
),
|
||||||
new(
|
new(
|
||||||
"avif-high",
|
"avif-high",
|
||||||
"고화질 아카이빙 (Q75)",
|
"고화질 아카이빙 (Q75)",
|
||||||
"색상 심도 10-bit HDR 보존",
|
"색상 심도 10-bit HDR 보존 및 디테일 유지",
|
||||||
new[] { "Quality 75", "10-bit HDR" },
|
new[] { "품질 75", "10-bit HDR", "고화질" },
|
||||||
opt => { opt.ImageQuality = 95; opt.StripMetadata = false; }
|
opt => { opt.ImageQuality = 95; opt.StripMetadata = false; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"avif-web-stream",
|
||||||
|
"웹 스트리밍 경량 (Q60)",
|
||||||
|
"빠른 디코딩 및 현대적 웹 브라우저 가속",
|
||||||
|
new[] { "품질 60", "웹 스트리밍", "빠른 로딩" },
|
||||||
|
opt => { opt.ImageQuality = 70; opt.StripMetadata = true; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"avif-compact-q45",
|
||||||
|
"극소 용량 보관 (Q45)",
|
||||||
|
"초고효율 AV1 압축으로 최소 용량 달성",
|
||||||
|
new[] { "품질 45", "초절약", "극소 용량" },
|
||||||
|
opt => { opt.ImageQuality = 55; opt.StripMetadata = true; }
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -198,23 +221,37 @@ public static class FormatPresetEngine
|
||||||
new(
|
new(
|
||||||
"jpg-photo-q95",
|
"jpg-photo-q95",
|
||||||
"디지털 인화·고화질 (Q95)",
|
"디지털 인화·고화질 (Q95)",
|
||||||
"Quality 95 · 색상 프로파일 보존 · 선명한 사진",
|
"품질 95 · 색상 프로파일 보존 · 선명한 사진",
|
||||||
new[] { "Quality 95", "ICC 보존", "고해상도" },
|
new[] { "품질 95", "ICC 보존", "고해상도" },
|
||||||
opt => { opt.ImageQuality = 95; opt.StripMetadata = false; }
|
opt => { opt.ImageQuality = 95; opt.StripMetadata = false; }
|
||||||
),
|
),
|
||||||
new(
|
new(
|
||||||
"jpg-web-q80",
|
"jpg-web-q80",
|
||||||
"웹 표준 (Q80 · 권장)",
|
"웹 표준 (Q80 · 권장)",
|
||||||
"Quality 80 · 프로그레시브 JPEG · 메타데이터 제거",
|
"품질 80 · 프로그레시브 JPEG · 메타데이터 제거",
|
||||||
new[] { "Quality 80", "Strip EXIF", "프로그레시브" },
|
new[] { "품질 80", "EXIF 제거", "프로그레시브" },
|
||||||
opt => { opt.ImageQuality = 80; opt.StripMetadata = true; }
|
opt => { opt.ImageQuality = 80; opt.StripMetadata = true; }
|
||||||
),
|
),
|
||||||
new(
|
new(
|
||||||
"jpg-compact-q70",
|
"jpg-compact-q70",
|
||||||
"모바일 메신저 (Q70)",
|
"모바일 메신저 (Q70)",
|
||||||
"Quality 70 · 카카오톡/문자 전송 가벼운 용량",
|
"품질 70 · 카카오톡/문자 전송 가벼운 용량",
|
||||||
new[] { "Quality 70", "용량 절약" },
|
new[] { "품질 70", "용량 절약", "모바일 최적화" },
|
||||||
opt => { opt.ImageQuality = 70; opt.StripMetadata = true; }
|
opt => { opt.ImageQuality = 70; opt.StripMetadata = true; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"jpg-archive-q100",
|
||||||
|
"아카이빙 무손실급 (Q100)",
|
||||||
|
"최고 화질 보존 · 메타데이터 및 ICC 완전 유지",
|
||||||
|
new[] { "품질 100", "원본 보존", "무손실급" },
|
||||||
|
opt => { opt.ImageQuality = 100; opt.StripMetadata = false; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"jpg-thumb-q60",
|
||||||
|
"경량 썸네일 (Q60)",
|
||||||
|
"빠른 로딩을 위한 인덱스 썸네일 전용",
|
||||||
|
new[] { "품질 60", "썸네일", "초경량" },
|
||||||
|
opt => { opt.ImageQuality = 60; opt.StripMetadata = true; }
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -233,6 +270,70 @@ public static class FormatPresetEngine
|
||||||
"불필요한 청크 제거 · 알파 투명도 보존",
|
"불필요한 청크 제거 · 알파 투명도 보존",
|
||||||
new[] { "웹 최적화", "투명도 보존", "EXIF 제거" },
|
new[] { "웹 최적화", "투명도 보존", "EXIF 제거" },
|
||||||
opt => { opt.ImageQuality = 100; opt.StripMetadata = true; }
|
opt => { opt.ImageQuality = 100; opt.StripMetadata = true; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"png-clean-alpha",
|
||||||
|
"클린 알파 투명도 보존",
|
||||||
|
"아이콘, 로고, UI 에셋 투명 레이어 무손실",
|
||||||
|
new[] { "알파 채널", "아이콘/UI", "투명도 무손실" },
|
||||||
|
opt => { opt.ImageQuality = 100; opt.StripMetadata = false; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"png-uncompressed",
|
||||||
|
"무압축 고속 출력",
|
||||||
|
"압축 딜레이 없는 즉시 저장 및 렌더링",
|
||||||
|
new[] { "고속 저장", "무손실", "CPU 절약" },
|
||||||
|
opt => { opt.ImageQuality = 100; opt.StripMetadata = false; }
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IReadOnlyList<FormatPreset> GifPresets() => new List<FormatPreset>
|
||||||
|
{
|
||||||
|
new(
|
||||||
|
"gif-web-256",
|
||||||
|
"웹 애니메이션 표준 (256색)",
|
||||||
|
"적응형 팔레트 · 디더링 적용으로 선명한 색감",
|
||||||
|
new[] { "256 Colors", "디더링", "웹 표준" },
|
||||||
|
opt => { opt.Quality = 85; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"gif-compact-128",
|
||||||
|
"초경량 메신저 GIF (128색)",
|
||||||
|
"용량 축소 팔레트 · 프레임 레이트 최적화",
|
||||||
|
new[] { "128 Colors", "용량 절약", "메신저" },
|
||||||
|
opt => { opt.Quality = 65; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"gif-sharp-64",
|
||||||
|
"고압축 그래픽 (64색)",
|
||||||
|
"심플 아이콘 및 UI 애니메이션 극소 용량",
|
||||||
|
new[] { "64 Colors", "초소형", "UI 그래픽" },
|
||||||
|
opt => { opt.Quality = 50; }
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IReadOnlyList<FormatPreset> HeicPresets() => new List<FormatPreset>
|
||||||
|
{
|
||||||
|
new(
|
||||||
|
"heic-original-q90",
|
||||||
|
"Apple 고화질 보존 (Q90)",
|
||||||
|
"아이폰 원본급 HEVC 압축 · 라이브 포토 및 HDR 보존",
|
||||||
|
new[] { "Quality 90", "Apple HEVC", "HDR 보존" },
|
||||||
|
opt => { opt.ImageQuality = 90; opt.StripMetadata = false; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"heic-balanced-q80",
|
||||||
|
"균형 공유 (Q80 · 추천)",
|
||||||
|
"표준 HEIF 압축 · 호환성과 용량 균형",
|
||||||
|
new[] { "Quality 80", "표준 압축", "용량 절약" },
|
||||||
|
opt => { opt.ImageQuality = 80; opt.StripMetadata = true; }
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
"heic-compact-q65",
|
||||||
|
"초경량 아카이빙 (Q65)",
|
||||||
|
"대용량 사진첩 백업용 극소 용량",
|
||||||
|
new[] { "Quality 65", "초경량", "백업 전용" },
|
||||||
|
opt => { opt.ImageQuality = 65; opt.StripMetadata = true; }
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -291,6 +392,13 @@ public static class FormatPresetEngine
|
||||||
new[] { "원본 해상도", "CRF 18", "Medium", "고화질 보존" },
|
new[] { "원본 해상도", "CRF 18", "Medium", "고화질 보존" },
|
||||||
opt => { opt.VideoCrf = 18; opt.VideoPreset = "medium"; opt.ResolutionIndex = 0; opt.AudioBitrateKbps = 320; }
|
opt => { opt.VideoCrf = 18; opt.VideoPreset = "medium"; opt.ResolutionIndex = 0; opt.AudioBitrateKbps = 320; }
|
||||||
),
|
),
|
||||||
|
new(
|
||||||
|
"mp4-4k-uhd",
|
||||||
|
"4K UHD 아카이빙 (CRF 16)",
|
||||||
|
"H.264 High Profile · 마스터링 고화질 아카이빙",
|
||||||
|
new[] { "4K UHD", "CRF 16", "마스터링", "고비트레이트" },
|
||||||
|
opt => { opt.VideoCrf = 16; opt.VideoPreset = "slow"; opt.ResolutionIndex = 1; opt.AudioBitrateKbps = 320; }
|
||||||
|
),
|
||||||
new(
|
new(
|
||||||
"mp4-audio-extract",
|
"mp4-audio-extract",
|
||||||
"오디오 트랙 추출 (AAC)",
|
"오디오 트랙 추출 (AAC)",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ public static class ServiceCollectionExtensions
|
||||||
else
|
else
|
||||||
services.TryAddSingleton<ISettingsStore, DpapiSettingsStore>();
|
services.TryAddSingleton<ISettingsStore, DpapiSettingsStore>();
|
||||||
|
|
||||||
|
services.TryAddSingleton<Everything2Everything.Core.Ads.IAdService, Everything2Everything.Core.Ads.AdService>();
|
||||||
|
|
||||||
// IConverterProvider 구현 전수 자동 등록. AsSelfWithInterfaces =
|
// IConverterProvider 구현 전수 자동 등록. AsSelfWithInterfaces =
|
||||||
// 구체 타입을 단일 싱글턴으로 등록 + IConverterProvider는 그 인스턴스로 포워드한다.
|
// 구체 타입을 단일 싱글턴으로 등록 + IConverterProvider는 그 인스턴스로 포워드한다.
|
||||||
// → Heic(MagickProvider)/Docx·Ocr·Hwpx(PdfProvider)/Llm(ISettingsStore) 생성자 의존이
|
// → Heic(MagickProvider)/Docx·Ocr·Hwpx(PdfProvider)/Llm(ISettingsStore) 생성자 의존이
|
||||||
|
|
|
||||||
105
src/Everything2Everything.Tests/AdDesignAuditTests.cs
Normal file
105
src/Everything2Everything.Tests/AdDesignAuditTests.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class AdDesignAuditTests
|
||||||
|
{
|
||||||
|
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("솔루션 루트를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdControls_MustExistAndBeWellFormed()
|
||||||
|
{
|
||||||
|
var bannerPath = Path.Combine(ViewsDir, "AdBannerControl.xaml");
|
||||||
|
var largeCardPath = Path.Combine(ViewsDir, "AdLargeCardControl.xaml");
|
||||||
|
|
||||||
|
Assert.True(File.Exists(bannerPath), "AdBannerControl.xaml 이 존재해야 합니다.");
|
||||||
|
Assert.True(File.Exists(largeCardPath), "AdLargeCardControl.xaml 이 존재해야 합니다.");
|
||||||
|
|
||||||
|
var bannerDoc = XDocument.Parse(File.ReadAllText(bannerPath));
|
||||||
|
var largeCardDoc = XDocument.Parse(File.ReadAllText(largeCardPath));
|
||||||
|
|
||||||
|
Assert.NotNull(bannerDoc.Root);
|
||||||
|
Assert.NotNull(largeCardDoc.Root);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdControls_MustComplyWithStrictDesignAudit()
|
||||||
|
{
|
||||||
|
var targetFiles = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(ViewsDir, "AdBannerControl.xaml"),
|
||||||
|
Path.Combine(ViewsDir, "AdLargeCardControl.xaml")
|
||||||
|
};
|
||||||
|
|
||||||
|
var rawEmojis = new[] { "⚙", "📁", "🗑", "✖", "🔧", "✨", "🚀", "⚡" };
|
||||||
|
var violations = new List<string>();
|
||||||
|
|
||||||
|
foreach (var file in targetFiles)
|
||||||
|
{
|
||||||
|
if (!File.Exists(file)) continue;
|
||||||
|
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
var fileName = Path.GetFileName(file);
|
||||||
|
|
||||||
|
// 1. 유니코드 이모지 금지 검증
|
||||||
|
var buttons = doc.Descendants().Where(e => e.Name.LocalName is "Button" or "ToggleButton");
|
||||||
|
foreach (var btn in buttons)
|
||||||
|
{
|
||||||
|
var content = btn.Attribute("Content")?.Value;
|
||||||
|
if (!string.IsNullOrEmpty(content) && rawEmojis.Any(emoji => content.Contains(emoji)))
|
||||||
|
{
|
||||||
|
violations.Add($"[{fileName}] 버튼에 이모지 사용 감지: {content}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 가로 StackPanel 내 아이콘/텍스트 VerticalAlignment="Center" 검증
|
||||||
|
var stackPanels = doc.Descendants().Where(e => e.Name.LocalName == "StackPanel" &&
|
||||||
|
e.Attribute("Orientation")?.Value == "Horizontal");
|
||||||
|
foreach (var sp in stackPanels)
|
||||||
|
{
|
||||||
|
foreach (var child in sp.Elements())
|
||||||
|
{
|
||||||
|
var va = child.Attribute("VerticalAlignment")?.Value;
|
||||||
|
if (va != "Center")
|
||||||
|
{
|
||||||
|
violations.Add($"[{fileName}] 가로 StackPanel의 자식 <{child.Name.LocalName}>에 VerticalAlignment=\"Center\" 누락");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. TextBlock 오버플로 방지 (TextTrimming 또는 TextWrapping) 검증
|
||||||
|
var textBlocks = doc.Descendants().Where(e => e.Name.LocalName == "TextBlock");
|
||||||
|
foreach (var tb in textBlocks)
|
||||||
|
{
|
||||||
|
var text = tb.Attribute("Text")?.Value ?? "";
|
||||||
|
// 바인딩된 동적 텍스트는 Trimming이나 Wrapping 필수
|
||||||
|
if (text.Contains("{Binding") || text.Contains("{x:Bind"))
|
||||||
|
{
|
||||||
|
var trimming = tb.Attribute("TextTrimming")?.Value;
|
||||||
|
var wrapping = tb.Attribute("TextWrapping")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(trimming) && string.IsNullOrEmpty(wrapping))
|
||||||
|
{
|
||||||
|
violations.Add($"[{fileName}] 바인딩 TextBlock에 TextTrimming 또는 TextWrapping 누락: {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(violations.Count == 0,
|
||||||
|
"광고 컨트롤 디자인 감사 위반이 발견되었습니다:\n" + string.Join("\n", violations));
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Everything2Everything.Tests/AdMobConfigTests.cs
Normal file
30
src/Everything2Everything.Tests/AdMobConfigTests.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using Everything2Everything.Core.Ads;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class AdMobConfigTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AdMobConfig_DefaultState_IsValid()
|
||||||
|
{
|
||||||
|
var config = new AdMobConfig();
|
||||||
|
|
||||||
|
Assert.NotNull(config.AppId);
|
||||||
|
Assert.NotNull(config.BannerAdUnitId);
|
||||||
|
Assert.NotNull(config.InterstitialAdUnitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateAppAdsTxt_ReturnsValidGoogleSpecification()
|
||||||
|
{
|
||||||
|
var config = new AdMobConfig
|
||||||
|
{
|
||||||
|
PublisherId = "pub-1234567890123456"
|
||||||
|
};
|
||||||
|
|
||||||
|
string appAdsTxt = config.GenerateAppAdsTxt();
|
||||||
|
|
||||||
|
Assert.Contains("google.com, pub-1234567890123456, DIRECT, f08c47fec0942fa0", appAdsTxt);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/Everything2Everything.Tests/AdServiceTests.cs
Normal file
117
src/Everything2Everything.Tests/AdServiceTests.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Everything2Everything.Core.Ads;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class AdServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetBannerAds_ReturnsDefaultHouseAds_NotEmpty()
|
||||||
|
{
|
||||||
|
var service = new AdService();
|
||||||
|
var bannerAds = service.GetBannerAds();
|
||||||
|
|
||||||
|
Assert.NotNull(bannerAds);
|
||||||
|
Assert.NotEmpty(bannerAds);
|
||||||
|
Assert.All(bannerAds, ad =>
|
||||||
|
{
|
||||||
|
Assert.Equal(AdType.Banner, ad.Type);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.Title));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.Description));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.TargetUrl));
|
||||||
|
Assert.Contains("git.chanpaca.net", ad.TargetUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLargeCardAds_ReturnsDefaultLargeCardAds_NotEmpty()
|
||||||
|
{
|
||||||
|
var service = new AdService();
|
||||||
|
var largeAds = service.GetLargeCardAds();
|
||||||
|
|
||||||
|
Assert.NotNull(largeAds);
|
||||||
|
Assert.NotEmpty(largeAds);
|
||||||
|
Assert.All(largeAds, ad =>
|
||||||
|
{
|
||||||
|
Assert.Equal(AdType.LargeCard, ad.Type);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.Title));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.Description));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.TargetUrl));
|
||||||
|
Assert.Contains("git.chanpaca.net", ad.TargetUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetNextBannerAd_RotatesThroughAds()
|
||||||
|
{
|
||||||
|
var service = new AdService();
|
||||||
|
var banners = service.GetBannerAds();
|
||||||
|
|
||||||
|
if (banners.Count > 1)
|
||||||
|
{
|
||||||
|
var first = service.GetNextBannerAd();
|
||||||
|
var second = service.GetNextBannerAd();
|
||||||
|
|
||||||
|
Assert.NotNull(first);
|
||||||
|
Assert.NotNull(second);
|
||||||
|
Assert.NotEqual(first.Id, second.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ad = service.GetNextBannerAd();
|
||||||
|
Assert.NotNull(ad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenAdUrl_InvalidOrEmptyUrl_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var service = new AdService();
|
||||||
|
|
||||||
|
Assert.False(service.OpenAdUrl(new AdItem { TargetUrl = "" }));
|
||||||
|
Assert.False(service.OpenAdUrl(new AdItem { TargetUrl = "javascript:alert(1)" }));
|
||||||
|
Assert.False(service.OpenAdUrl(new AdItem { TargetUrl = "not-a-valid-url" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadAdsFromJson_ValidJson_PopulatesCustomAds()
|
||||||
|
{
|
||||||
|
var service = new AdService();
|
||||||
|
string json = @"[
|
||||||
|
{
|
||||||
|
""Id"": ""test-banner-1"",
|
||||||
|
""Title"": ""커스텀 파트너스 배너"",
|
||||||
|
""Description"": ""안전하고 빠른 클라우드 스토리지"",
|
||||||
|
""BadgeText"": ""스폰서"",
|
||||||
|
""ImageUrl"": ""https://example.com/banner.png"",
|
||||||
|
""TargetUrl"": ""https://example.com/promo"",
|
||||||
|
""CtaText"": ""지금 확인"",
|
||||||
|
""Type"": 0,
|
||||||
|
""IsActive"": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
""Id"": ""test-large-1"",
|
||||||
|
""Title"": ""대용량 미디어 솔루션"",
|
||||||
|
""Description"": ""초고속 무손실 변환을 비즈니스에 도입하세요"",
|
||||||
|
""BadgeText"": ""AD"",
|
||||||
|
""ImageUrl"": ""https://example.com/large.png"",
|
||||||
|
""TargetUrl"": ""https://example.com/b2b"",
|
||||||
|
""CtaText"": ""솔루션 보기"",
|
||||||
|
""Type"": 1,
|
||||||
|
""IsActive"": true
|
||||||
|
}
|
||||||
|
]";
|
||||||
|
|
||||||
|
int count = service.LoadAdsFromJson(json);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
var banners = service.GetBannerAds();
|
||||||
|
var largeCards = service.GetLargeCardAds();
|
||||||
|
|
||||||
|
Assert.Contains(banners, b => b.Id == "test-banner-1");
|
||||||
|
Assert.Contains(largeCards, l => l.Id == "test-large-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/Everything2Everything.Tests/AdViewModelTests.cs
Normal file
102
src/Everything2Everything.Tests/AdViewModelTests.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Everything2Everything.App.ViewModels;
|
||||||
|
using Everything2Everything.Core.Ads;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class AdViewModelTests
|
||||||
|
{
|
||||||
|
private class FakeAdService : IAdService
|
||||||
|
{
|
||||||
|
public List<AdItem> Banners { get; } = new()
|
||||||
|
{
|
||||||
|
new AdItem { Id = "b1", Title = "배너 1", TargetUrl = "https://example.com/b1", Type = AdType.Banner },
|
||||||
|
new AdItem { Id = "b2", Title = "배너 2", TargetUrl = "https://example.com/b2", Type = AdType.Banner }
|
||||||
|
};
|
||||||
|
|
||||||
|
public List<AdItem> Cards { get; } = new()
|
||||||
|
{
|
||||||
|
new AdItem { Id = "c1", Title = "카드 1", TargetUrl = "https://example.com/c1", Type = AdType.LargeCard },
|
||||||
|
new AdItem { Id = "c2", Title = "카드 2", TargetUrl = "https://example.com/c2", Type = AdType.LargeCard }
|
||||||
|
};
|
||||||
|
|
||||||
|
public AdItem? LastOpenedAd { get; private set; }
|
||||||
|
private int _bIndex = 0;
|
||||||
|
private int _cIndex = 0;
|
||||||
|
|
||||||
|
public IReadOnlyList<AdItem> GetBannerAds() => Banners;
|
||||||
|
public IReadOnlyList<AdItem> GetLargeCardAds() => Cards;
|
||||||
|
|
||||||
|
public AdItem? GetNextBannerAd()
|
||||||
|
{
|
||||||
|
var ad = Banners[_bIndex % Banners.Count];
|
||||||
|
_bIndex++;
|
||||||
|
return ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdItem? GetNextLargeCardAd()
|
||||||
|
{
|
||||||
|
var ad = Cards[_cIndex % Cards.Count];
|
||||||
|
_cIndex++;
|
||||||
|
return ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
public System.Threading.Tasks.Task<int> RefreshAdsFromFeedAsync(string feedUrl, System.Threading.CancellationToken cancellationToken = default) => System.Threading.Tasks.Task.FromResult(0);
|
||||||
|
public int LoadAdsFromJson(string json) => 0;
|
||||||
|
|
||||||
|
public bool OpenAdUrl(AdItem ad)
|
||||||
|
{
|
||||||
|
LastOpenedAd = ad;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_InitializesAdsAndVisibility()
|
||||||
|
{
|
||||||
|
var fake = new FakeAdService();
|
||||||
|
var vm = new AdViewModel(fake);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.CurrentBannerAd);
|
||||||
|
Assert.NotNull(vm.CurrentLargeAd);
|
||||||
|
Assert.True(vm.IsBannerVisible);
|
||||||
|
Assert.True(vm.IsLargeCardVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClickBannerAdCommand_OpensAdUrl()
|
||||||
|
{
|
||||||
|
var fake = new FakeAdService();
|
||||||
|
var vm = new AdViewModel(fake);
|
||||||
|
|
||||||
|
vm.ClickBannerAdCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(fake.LastOpenedAd);
|
||||||
|
Assert.Equal(vm.CurrentBannerAd?.Id, fake.LastOpenedAd.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DismissBannerCommand_HidesBanner()
|
||||||
|
{
|
||||||
|
var fake = new FakeAdService();
|
||||||
|
var vm = new AdViewModel(fake);
|
||||||
|
|
||||||
|
Assert.True(vm.IsBannerVisible);
|
||||||
|
vm.DismissBannerCommand.Execute(null);
|
||||||
|
Assert.False(vm.IsBannerVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RotateBannerAd_ChangesCurrentBanner()
|
||||||
|
{
|
||||||
|
var fake = new FakeAdService();
|
||||||
|
var vm = new AdViewModel(fake);
|
||||||
|
|
||||||
|
var firstId = vm.CurrentBannerAd?.Id;
|
||||||
|
vm.RotateBannerAd();
|
||||||
|
var secondId = vm.CurrentBannerAd?.Id;
|
||||||
|
|
||||||
|
Assert.NotEqual(firstId, secondId);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Everything2Everything.Tests/AgyChatClientTests.cs
Normal file
58
src/Everything2Everything.Tests/AgyChatClientTests.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Everything2Everything.Core;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class AgyChatClientTests
|
||||||
|
{
|
||||||
|
private sealed class FakeStore : 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExternalToolDetector_CanDetectAgy()
|
||||||
|
{
|
||||||
|
var available = ExternalToolDetector.IsAgyAvailable(out var path);
|
||||||
|
if (!available) return;
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(path));
|
||||||
|
Assert.EndsWith("agy.exe", path, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AgyChatClient_HasCorrectName()
|
||||||
|
{
|
||||||
|
var client = new AgyChatClient();
|
||||||
|
Assert.Equal("Antigravity CLI (agy)", client.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LlmProvider_ResolveClient_AutoSelectsAgyWhenNoKey()
|
||||||
|
{
|
||||||
|
if (!ExternalToolDetector.IsAgyAvailable(out _)) return;
|
||||||
|
var store = new FakeStore();
|
||||||
|
var provider = new LlmProvider(store);
|
||||||
|
var (client, model) = provider.ResolveClientForTesting(new AiOptions { Backend = "agy" });
|
||||||
|
Assert.NotNull(client);
|
||||||
|
Assert.IsType<AgyChatClient>(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LlmProvider_CheckAvailability_IsReadyWhenAgyAvailable()
|
||||||
|
{
|
||||||
|
if (!ExternalToolDetector.IsAgyAvailable(out _)) return;
|
||||||
|
var store = new FakeStore();
|
||||||
|
var provider = new LlmProvider(store);
|
||||||
|
var availability = await provider.CheckAvailabilityAsync();
|
||||||
|
Assert.True(availability.IsReady);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
53
src/Everything2Everything.Tests/CategoryGlyphsTests.cs
Normal file
53
src/Everything2Everything.Tests/CategoryGlyphsTests.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using Everything2Everything.App.Views;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class CategoryGlyphsTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(".jpg", 80)]
|
||||||
|
[InlineData(".mp4", 80)]
|
||||||
|
[InlineData(".pdf", 80)]
|
||||||
|
[InlineData(".csv", 80)]
|
||||||
|
public void ForExtension_ReturnsValidImageSource_WithProperPackUri(string ext, int minDimension)
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var img = CategoryGlyphs.ForExtension(ext) as BitmapImage;
|
||||||
|
Assert.NotNull(img);
|
||||||
|
Assert.Contains("Everything2Everything;component", img.UriSource.OriginalString);
|
||||||
|
Assert.True(img.PixelWidth >= minDimension, $"Glyph for {ext} should have width >= {minDimension}");
|
||||||
|
Assert.True(img.PixelHeight >= minDimension, $"Glyph for {ext} should have height >= {minDimension}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunOnSta(Action action)
|
||||||
|
{
|
||||||
|
Exception? ex = null;
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Application.Current == null)
|
||||||
|
{
|
||||||
|
try { _ = new Application(); } catch { }
|
||||||
|
}
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
ex = e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
thread.Join();
|
||||||
|
|
||||||
|
if (ex != null) throw new AggregateException("STA failure", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ public class DependencyInjectionTests
|
||||||
|
|
||||||
Assert.NotNull(sp.GetRequiredService<ProviderRegistry>());
|
Assert.NotNull(sp.GetRequiredService<ProviderRegistry>());
|
||||||
Assert.NotNull(sp.GetRequiredService<ConversionEngine>());
|
Assert.NotNull(sp.GetRequiredService<ConversionEngine>());
|
||||||
|
Assert.NotNull(sp.GetRequiredService<Everything2Everything.Core.Ads.IAdService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -419,6 +419,725 @@ public class DesignAuditAstTests
|
||||||
Assert.NotNull(cyanBgBrush);
|
Assert.NotNull(cyanBgBrush);
|
||||||
Assert.Equal("#083344", cyanBgBrush.Attribute("Color")?.Value);
|
Assert.Equal("#083344", cyanBgBrush.Attribute("Color")?.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InteractiveElements_MustHave_AutomationPropertiesAutomationId_For_E2E_Reliability()
|
||||||
|
{
|
||||||
|
// UI Automation 기반 Headful E2E 테스트 및 접근성(A11y) 신뢰성을 위해 핵심 인터랙티브 요소는 AutomationId가 지정되어야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var requiredAutomationIds = new[]
|
||||||
|
{
|
||||||
|
"ProcessQueueButton",
|
||||||
|
"AdvancedOptionsExpander",
|
||||||
|
"OutputFormatCombo",
|
||||||
|
"ImageLosslessCheck",
|
||||||
|
"PdfCompressLevelCombo",
|
||||||
|
"PdfDpiCombo",
|
||||||
|
"QualitySlider",
|
||||||
|
"VideoCrfSlider",
|
||||||
|
"AudioBitrateQuickCombo",
|
||||||
|
"PdfCompressQuickCombo"
|
||||||
|
};
|
||||||
|
|
||||||
|
var foundAutomationIds = doc.Descendants()
|
||||||
|
.Select(e => e.Attribute(XName.Get("AutomationId", "clr-namespace:System.Windows.Automation;assembly=PresentationCore"))?.Value
|
||||||
|
?? e.Attribute("AutomationProperties.AutomationId")?.Value)
|
||||||
|
.Where(id => !string.IsNullOrEmpty(id))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var missing = requiredAutomationIds.Where(req => !foundAutomationIds.Contains(req)).ToList();
|
||||||
|
Assert.True(missing.Count == 0,
|
||||||
|
$"핵심 인터랙티브 요소에 AutomationProperties.AutomationId가 누락되었습니다: {string.Join(", ", missing)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sidebar_ConflictRuleSection_MustUsePureKoreanLabels()
|
||||||
|
{
|
||||||
|
// designpaca & AGENTS.md Rule 3:
|
||||||
|
// 영문 대문자 'FILE CONFLICT RULE' 및 영문 버튼 'Skip', 'Rename', 'Replace'를 금지하고
|
||||||
|
// 정제된 한국어 표준 '파일 충돌 해결', '건너뛰기', '이름 변경', '덮어쓰기'로 일관되게 제공해야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var allTexts = doc.Descendants().Where(e => e.Name.LocalName == "TextBlock")
|
||||||
|
.Select(t => t.Attribute("Text")?.Value ?? t.Value)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(allTexts, t => t.Contains("FILE CONFLICT RULE", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var skipBtn = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ConflictSkipBtn");
|
||||||
|
var renameBtn = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ConflictRenameBtn");
|
||||||
|
var replaceBtn = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ConflictReplaceBtn");
|
||||||
|
|
||||||
|
Assert.NotNull(skipBtn);
|
||||||
|
Assert.NotNull(renameBtn);
|
||||||
|
Assert.NotNull(replaceBtn);
|
||||||
|
|
||||||
|
Assert.Equal("건너뛰기", skipBtn.Attribute("Content")?.Value);
|
||||||
|
Assert.Equal("이름 변경", renameBtn.Attribute("Content")?.Value);
|
||||||
|
Assert.Equal("덮어쓰기", replaceBtn.Attribute("Content")?.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatShiftTheme_MustDefine_IslandButtonStyle_And_DoubleBezelStyles()
|
||||||
|
{
|
||||||
|
// high-end-visual-design Section 4:
|
||||||
|
// Island Button (Button-in-Button) 스타일 및 Double-Bezel 카드 쉘 스타일이 선언되어야 한다.
|
||||||
|
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("FsIslandPrimaryButtonStyle", keys);
|
||||||
|
Assert.Contains("FsDoubleBezelShellStyle", keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SegmentedTabs_MustUsePureKorean_And_NotContainEnglishRawLabels()
|
||||||
|
{
|
||||||
|
// AGENTS.md Rule 3:
|
||||||
|
// 상단 내비게이션 탭은 영문 'Active Queue', 'Past Results' 대신 '대기열', '변환 기록'을 사용해야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var tabActive = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "TabActiveBtn");
|
||||||
|
var tabPast = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "TabPastBtn");
|
||||||
|
|
||||||
|
Assert.NotNull(tabActive);
|
||||||
|
Assert.NotNull(tabPast);
|
||||||
|
|
||||||
|
var activeText = tabActive.Descendants().Where(e => e.Name.LocalName == "TextBlock").Select(t => t.Attribute("Text")?.Value ?? t.Value).FirstOrDefault();
|
||||||
|
var pastText = tabPast.Descendants().Where(e => e.Name.LocalName == "TextBlock").Select(t => t.Attribute("Text")?.Value ?? t.Value).FirstOrDefault();
|
||||||
|
|
||||||
|
Assert.Equal("대기열", activeText);
|
||||||
|
Assert.Equal("변환 기록", pastText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sidebar_CombineCheck_MustWrapText_ToPreventClipping()
|
||||||
|
{
|
||||||
|
// Design Audit Invariant 4: 컨테이너 오버플로 및 텍스트 생략 방지
|
||||||
|
// CombineToSingleCheck 내부의 텍스트가 잘리지 않도록 TextBlock에 TextWrapping="Wrap"이 선언되어야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var combineCheck = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "CombineToSingleCheck");
|
||||||
|
Assert.NotNull(combineCheck);
|
||||||
|
|
||||||
|
var childTb = combineCheck.Descendants().FirstOrDefault(e => e.Name.LocalName == "TextBlock");
|
||||||
|
Assert.NotNull(childTb);
|
||||||
|
Assert.Equal("Wrap", childTb.Attribute("TextWrapping")?.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProcessQueueButton_DefaultText_MustBeCompact_ToPreventOverflow()
|
||||||
|
{
|
||||||
|
// 280px 너비의 사이드바 내 Island 버튼(버튼 내 원형 화살표 포함)에서
|
||||||
|
// '대기 중 — 파일을 드래그하여 추가하세요'와 같은 긴 텍스트는 글자 잘림을 유발하므로
|
||||||
|
// '파일을 드래그하여 추가' 등 15자 이하의 정갈한 텍스트를 사용해야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var btn = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ProcessQueueButton");
|
||||||
|
Assert.NotNull(btn);
|
||||||
|
|
||||||
|
var content = btn.Attribute("Content")?.Value;
|
||||||
|
Assert.NotNull(content);
|
||||||
|
Assert.True(content.Length <= 15, $"ProcessQueueButton의 텍스트가 너무 길어 잘림이 발생합니다: '{content}' ({content.Length}자)");
|
||||||
|
Assert.Equal("파일을 드래그하여 추가", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sidebar_AiTaskAndEncodingCards_MustUseDoubleBezelShell_ForUniformStyle()
|
||||||
|
{
|
||||||
|
// high-end-visual-design Section 4:
|
||||||
|
// 사이드바의 AI 작업 및 상세 인코딩 설정 카드도 FsDoubleBezelShellStyle로 통일되어 일관된 머신 하드웨어 룩을 완성해야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var aiPanel = doc.Descendants().FirstOrDefault(d => d.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "AiTaskPanel");
|
||||||
|
Assert.NotNull(aiPanel);
|
||||||
|
|
||||||
|
var outerBorder = aiPanel.Ancestors().Where(a => a.Name.LocalName == "Border").Skip(1).FirstOrDefault();
|
||||||
|
Assert.NotNull(outerBorder);
|
||||||
|
Assert.Equal("{StaticResource FsDoubleBezelShellStyle}", outerBorder.Attribute("Style")?.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveQueueList_StateText_MustBindToDisplayStateText_ForKoreanLocalization()
|
||||||
|
{
|
||||||
|
// AGENTS.md Rule 3:
|
||||||
|
// 대기열 목록의 상태 표시는 날것의 영문 'queued', 'done' 대신
|
||||||
|
// 한국어 '대기 중', '변환 완료'를 제공하는 DisplayStateText에 바인딩되어야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var queueList = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ActiveQueueList");
|
||||||
|
Assert.NotNull(queueList);
|
||||||
|
|
||||||
|
var boundTexts = queueList.Descendants().Where(e => e.Name.LocalName == "TextBlock")
|
||||||
|
.Select(t => t.Attribute("Text")?.Value)
|
||||||
|
.Where(v => v != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Contains("{Binding DisplayStateText}", boundTexts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_Title_MustBe_Everything2Everything_And_NotContainFormatShiftUtility()
|
||||||
|
{
|
||||||
|
// 윈도우 타이틀은 레거시 'FormatShift Utility'가 아니라 공식 브랜드명 'Everything2Everything'이어야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var root = doc.Root;
|
||||||
|
Assert.NotNull(root);
|
||||||
|
|
||||||
|
var title = root.Attribute("Title")?.Value;
|
||||||
|
Assert.Equal("Everything2Everything", title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DropHintOverlay_MustUsePureKorean_And_NotContainEnglishRawLabels()
|
||||||
|
{
|
||||||
|
// 드래그앤드롭 오버레이 안내 텍스트는 영문 날것 'Drop to add to queue' 대신 정제된 한국어 '파일을 놓아 대기열에 추가'여야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var overlay = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "DropHintOverlay");
|
||||||
|
Assert.NotNull(overlay);
|
||||||
|
|
||||||
|
var texts = overlay.Descendants().Where(e => e.Name.LocalName == "TextBlock")
|
||||||
|
.Select(t => t.Attribute("Text")?.Value)
|
||||||
|
.Where(v => v != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(texts, t => t!.Contains("Drop to add to queue", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.Contains(texts, t => t!.Contains("대기열"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SmartConversionDeck_MustDirectlyContain_AdvancedOptionsExpander_Below_Presets()
|
||||||
|
{
|
||||||
|
// Fluent 2 점진적 공개(Progressive Disclosure) 및 사용자 피드백 원칙:
|
||||||
|
// 상세 설정 폴드아웃(AdvancedOptionsExpander)은 저장 위치나 AI 작업 카드의 한참 아래가 아니라,
|
||||||
|
// 프리셋 선택 즉시 펼쳐서 미세조정할 수 있도록 '스마트 변환 덱'(SmartPresetCombo가 있는 첫 번째 카드) 내부에 직결되어야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var presetCombo = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SmartPresetCombo");
|
||||||
|
Assert.NotNull(presetCombo);
|
||||||
|
|
||||||
|
var expander = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "AdvancedOptionsExpander");
|
||||||
|
Assert.NotNull(expander);
|
||||||
|
|
||||||
|
// SmartPresetCombo의 가장 가까운 Border 부모 (스마트 변환 덱 카드)
|
||||||
|
var deckCard = presetCombo.Ancestors().FirstOrDefault(a => a.Name.LocalName == "Border" && (a.Attribute("Style")?.Value?.Contains("FsCardStyle") == true || a.Attribute("Style")?.Value?.Contains("FsDoubleBezelShellStyle") == true));
|
||||||
|
Assert.NotNull(deckCard);
|
||||||
|
|
||||||
|
// AdvancedOptionsExpander가 바로 그 스마트 변환 덱 카드(deckCard)의 자손이어야 한다.
|
||||||
|
var isInsideDeck = deckCard.Descendants().Any(d => d == expander);
|
||||||
|
Assert.True(isInsideDeck, "AdvancedOptionsExpander는 스마트 프리셋 바로 아래에서 펼쳐질 수 있도록 스마트 변환 덱(deckCard) 내부에 위치해야 합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SmartConversionDeck_MustContain_AllQuickPanels_For_MediaTypes()
|
||||||
|
{
|
||||||
|
// 사용자 피드백: 프리셋만 퉁쳐져 있지 않고 이미지 품질, 비디오 CRF, 오디오 비트레이트, PDF 압축률 등
|
||||||
|
// 핵심 슬라이더와 선택기가 스마트 변환 덱에 직관적으로 배치되어야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var requiredPanels = new[] { "QualityPanel", "VideoQuickPanel", "AudioQuickPanel", "PdfQuickPanel" };
|
||||||
|
foreach (var panelName in requiredPanels)
|
||||||
|
{
|
||||||
|
var panel = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == panelName);
|
||||||
|
Assert.NotNull(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 패널에 연결된 컨트롤 및 슬라이더 확인
|
||||||
|
var qualitySlider = doc.Descendants().FirstOrDefault(e => e.Attribute("AutomationProperties.AutomationId")?.Value == "QualitySlider");
|
||||||
|
Assert.NotNull(qualitySlider);
|
||||||
|
|
||||||
|
var videoSlider = doc.Descendants().FirstOrDefault(e => e.Attribute("AutomationProperties.AutomationId")?.Value == "VideoCrfSlider");
|
||||||
|
Assert.NotNull(videoSlider);
|
||||||
|
|
||||||
|
var audioCombo = doc.Descendants().FirstOrDefault(e => e.Attribute("AutomationProperties.AutomationId")?.Value == "AudioBitrateQuickCombo");
|
||||||
|
Assert.NotNull(audioCombo);
|
||||||
|
|
||||||
|
var pdfCombo = doc.Descendants().FirstOrDefault(e => e.Attribute("AutomationProperties.AutomationId")?.Value == "PdfCompressQuickCombo");
|
||||||
|
Assert.NotNull(pdfCombo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PastResultsEmpty_MustUseDoubleBezelShell_And_PureKoreanTerminology()
|
||||||
|
{
|
||||||
|
// 변환 기록 빈 상태도 FsDoubleBezelShellStyle을 사용하여 DropZoneEmpty와 일관된 머신 룩을 제공해야 하며,
|
||||||
|
// 버튼 텍스트는 '큐'가 아닌 '대기열' 표준 한국어 용어를 사용해야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var emptyContainer = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PastResultsEmpty");
|
||||||
|
Assert.NotNull(emptyContainer);
|
||||||
|
|
||||||
|
var bezelShell = emptyContainer.Descendants().FirstOrDefault(e => e.Name.LocalName == "Border" && e.Attribute("Style")?.Value == "{StaticResource FsDoubleBezelShellStyle}");
|
||||||
|
Assert.NotNull(bezelShell);
|
||||||
|
|
||||||
|
var texts = emptyContainer.Descendants().Where(e => e.Name.LocalName == "TextBlock")
|
||||||
|
.Select(t => t.Attribute("Text")?.Value)
|
||||||
|
.Where(v => v != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(texts, t => t!.Contains("큐로"));
|
||||||
|
Assert.Contains(texts, t => t!.Contains("대기열로"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettingsWindow_Cards_MustUseDoubleBezelShell_ForUniformStyle()
|
||||||
|
{
|
||||||
|
// SettingsWindow의 AI 카드 및 외부 도구 카드도 FsDoubleBezelShellStyle 및 FsDoubleBezelCoreStyle을 사용하여
|
||||||
|
// 앱 전체와 동일한 스위스 미니멀 이중 베젤 아키텍처를 준수해야 한다.
|
||||||
|
var settingsFile = Path.Combine(ViewsDir, "SettingsWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(settingsFile));
|
||||||
|
|
||||||
|
var shells = doc.Descendants().Where(e => e.Name.LocalName == "Border" && e.Attribute("Style")?.Value == "{StaticResource FsDoubleBezelShellStyle}").ToList();
|
||||||
|
Assert.True(shells.Count >= 2, $"SettingsWindow는 최소 2개 이상의 이중 베젤 쉘 카드를 포함해야 합니다. (발견된 수: {shells.Count})");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sidebar_AllCards_MustUseDoubleBezelShell_ForUnifiedAesthetic()
|
||||||
|
{
|
||||||
|
// high-end-visual-design Section 4 & stitch-design-taste:
|
||||||
|
// 사이드바의 모든 덱(스마트 변환 덱, 저장 위치 및 충돌 해결 덱, AI 작업 덱, 통계 덱)은
|
||||||
|
// 단일 보더 플랫 카드(FsCardStyle)가 아닌 이중 베젤(FsDoubleBezelShellStyle + FsDoubleBezelCoreStyle)로 통일되어야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var sidebarScroll = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "ScrollViewer" && e.Ancestors().Any(a => a.Name.LocalName == "Border" && a.Attribute(XName.Get("Column", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "0" || a.Attribute("Grid.Column")?.Value == "0"));
|
||||||
|
Assert.NotNull(sidebarScroll);
|
||||||
|
|
||||||
|
var stackPanel = sidebarScroll.Descendants().FirstOrDefault(e => e.Name.LocalName == "StackPanel");
|
||||||
|
Assert.NotNull(stackPanel);
|
||||||
|
|
||||||
|
// stackPanel의 직계 자식 카드들
|
||||||
|
var directChildCards = stackPanel.Elements().Where(e => e.Name.LocalName == "Border").ToList();
|
||||||
|
Assert.True(directChildCards.Count >= 4, $"사이드바에는 최소 4개 이상의 카드가 있어야 합니다. (발견된 수: {directChildCards.Count})");
|
||||||
|
|
||||||
|
foreach (var card in directChildCards)
|
||||||
|
{
|
||||||
|
var style = card.Attribute("Style")?.Value;
|
||||||
|
Assert.Equal("{StaticResource FsDoubleBezelShellStyle}", style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PreviewPane_MustUsePureKorean_And_NotContainEnglishLabels()
|
||||||
|
{
|
||||||
|
// AGENTS.md 규약 3 & stitch-design-taste:
|
||||||
|
// 우측 미리보기 인스펙터 패널의 헤더, 빈 상태 안내문구, 메타데이터 레이블은
|
||||||
|
// 날것의 영어(PREVIEW, No selection, Active Queue, Format, Size, Dimensions, Pages) 대신
|
||||||
|
// 정갈하고 직관적인 한국어(미리보기, 선택된 항목 없음, 대기열, 형식, 크기, 해상도 등)를 사용해야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var textBlocks = doc.Descendants().Where(e => e.Name.LocalName == "TextBlock").ToList();
|
||||||
|
var allTexts = textBlocks.Select(t => t.Attribute("Text")?.Value).Where(v => v != null).ToList();
|
||||||
|
|
||||||
|
// 영어 레이블 금지
|
||||||
|
Assert.DoesNotContain(allTexts, t => t == "PREVIEW");
|
||||||
|
Assert.DoesNotContain(allTexts, t => t == "No selection");
|
||||||
|
Assert.DoesNotContain(allTexts, t => t != null && t.Contains("Active Queue 항목을 클릭하면"));
|
||||||
|
Assert.DoesNotContain(allTexts, t => t == "Format");
|
||||||
|
Assert.DoesNotContain(allTexts, t => t == "Size");
|
||||||
|
Assert.DoesNotContain(allTexts, t => t == "Dimensions");
|
||||||
|
Assert.DoesNotContain(allTexts, t => t == "Pages");
|
||||||
|
|
||||||
|
// 한국어 레이블 필수
|
||||||
|
Assert.Contains(allTexts, t => t == "선택된 항목 없음");
|
||||||
|
Assert.Contains(allTexts, t => t == "형식");
|
||||||
|
Assert.Contains(allTexts, t => t == "크기");
|
||||||
|
Assert.Contains(allTexts, t => t != null && t.Contains("해상도"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DropZoneEmpty_MustUseDoubleBezelShell_ForLinearTierMachinedLook()
|
||||||
|
{
|
||||||
|
// high-end-visual-design Section 4 (Double-Bezel):
|
||||||
|
// 중앙 메인 빈 상태 드롭존(DropZoneEmpty)도 단일 대시 사각형이 아닌
|
||||||
|
// FsDoubleBezelShellStyle + FsDoubleBezelCoreStyle 이중 베젤 쉘 아키텍처로 구현되어
|
||||||
|
// PastResultsEmpty 및 사이드바 덱들과 완벽한 하드웨어 머신 룩 통일감을 형성해야 한다.
|
||||||
|
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||||
|
|
||||||
|
var dropZone = doc.Descendants().FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "DropZoneEmpty");
|
||||||
|
Assert.NotNull(dropZone);
|
||||||
|
|
||||||
|
var bezelShell = dropZone.Descendants().FirstOrDefault(e => e.Name.LocalName == "Border" && e.Attribute("Style")?.Value == "{StaticResource FsDoubleBezelShellStyle}");
|
||||||
|
Assert.NotNull(bezelShell);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_CodeBehind_DateLabels_MustUseKorean_And_NotContainEnglishTodayOrYesterday()
|
||||||
|
{
|
||||||
|
// AGENTS.md 규약 3 & stitch-design-taste:
|
||||||
|
// 변환 기록 날짜 헤더(DateTitle)는 영문 'Today', 'Yesterday' 대신 정제된 한국어 '오늘', '어제'를 사용해야 한다.
|
||||||
|
var codeFile = Path.Combine(ViewsDir, "MainWindow.xaml.cs");
|
||||||
|
var code = File.ReadAllText(codeFile);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("\"Today\"", code);
|
||||||
|
Assert.DoesNotContain("\"Yesterday\"", code);
|
||||||
|
Assert.Contains("\"오늘", code);
|
||||||
|
Assert.Contains("\"어제", code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("MainWindow.xaml")]
|
||||||
|
[InlineData("SettingsWindow.xaml")]
|
||||||
|
[InlineData("DiagnoseWindow.xaml")]
|
||||||
|
[InlineData("QuickOptionsWindow.xaml")]
|
||||||
|
[InlineData("QuickProgressWindow.xaml")]
|
||||||
|
public void AllWindows_MustInclude_UiControlsDictionary_InResources(string xamlName)
|
||||||
|
{
|
||||||
|
// Wpf.Ui Fluent 2 컨트롤(ComboBox, Slider, Button 등)의 다크 테마 룩앤필이
|
||||||
|
// 독립 실행 및 In-Memory 렌더링 시에도 100% 보장되도록 각 윈도우의 MergedDictionaries는
|
||||||
|
// <ui:ControlsDictionary/>를 필수로 포함해야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, xamlName);
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var hasControlsDict = doc.Descendants().Any(e => e.Name.LocalName == "ControlsDictionary");
|
||||||
|
Assert.True(hasControlsDict, $"{xamlName}에 <ui:ControlsDictionary/>가 선언되어 있지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_CodeBehind_MustNotContain_RawEnglish_PastResults()
|
||||||
|
{
|
||||||
|
// AGENTS.md 규약 3 & stitch-design-taste:
|
||||||
|
// 메시지 박스 및 다이얼로그 안내 문구에서도 'Past Results' 날것의 영어를 금지하고
|
||||||
|
// '변환 기록' 표준 한국어로 안내해야 한다.
|
||||||
|
var codeFile = Path.Combine(ViewsDir, "MainWindow.xaml.cs");
|
||||||
|
var code = File.ReadAllText(codeFile);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("\"Past Results", code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PastResults_MetaLine_MustHave_TextTrimmingCharacterEllipsis_ToPreventOverflow()
|
||||||
|
{
|
||||||
|
// AGENTS.md Rule 4 (Overflow Safety):
|
||||||
|
// 긴 파일 메타데이터(타임스탬프 + 파일수)를 표시하는 TextBlock은
|
||||||
|
// 좁은 컬럼 폭에서도 오버플로/글리프 잘림 없이 안전하게 축약되어야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var metaTextBlocks = doc.Descendants()
|
||||||
|
.Where(e => e.Name.LocalName == "TextBlock" &&
|
||||||
|
e.Attribute("Text")?.Value == "{Binding MetaLine}")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.NotEmpty(metaTextBlocks);
|
||||||
|
foreach (var tb in metaTextBlocks)
|
||||||
|
{
|
||||||
|
var trimming = tb.Attribute("TextTrimming")?.Value;
|
||||||
|
Assert.True(trimming == "CharacterEllipsis",
|
||||||
|
"MetaLine TextBlock은 TextTrimming=\"CharacterEllipsis\"를 지정하여 오버플로를 방지해야 합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_SearchAndFilterToolbar_MustBeScopedToListColumn()
|
||||||
|
{
|
||||||
|
// 검색 및 카테고리 필터 바는 미리보기(Inspector) 위로 어색하게 500px 걸쳐있지 않고,
|
||||||
|
// 대상이 되는 대기열/기록 리스트 컬럼(Grid.Column="0") 내부에 정렬되어야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var searchBox = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "TextBox" &&
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SearchBox");
|
||||||
|
Assert.NotNull(searchBox);
|
||||||
|
|
||||||
|
// SearchBox를 감싸는 툴바 Border 찾기
|
||||||
|
var toolbarBorder = searchBox.Ancestors().FirstOrDefault(e => e.Name.LocalName == "Border" &&
|
||||||
|
e.Attribute("Grid.Row")?.Value == "0");
|
||||||
|
Assert.NotNull(toolbarBorder);
|
||||||
|
|
||||||
|
var colSpan = toolbarBorder.Attribute("Grid.ColumnSpan")?.Value;
|
||||||
|
Assert.True(string.IsNullOrEmpty(colSpan) || colSpan == "1",
|
||||||
|
"Search/Filter Toolbar는 Inspector 컬럼을 침범하는 ColumnSpan=\"2\"가 아니어야 하며, 리스트 컬럼(Column 0)에 정합되어야 합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveQueueList_Columns_MustProvideAdequateWidthForMetadata()
|
||||||
|
{
|
||||||
|
// 대기열 아이템 행 그리드에서 파일 크기 및 상태 컬럼이 과도하게 너비를 차지하여
|
||||||
|
// 파일명과 메타데이터 문자열이 중간에 잘리지 않도록 컬럼 너비를 정밀 최적화해야 한다.
|
||||||
|
// SizeText 컬럼 <= 80px, DisplayStateText 컬럼 <= 90px
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var queueList = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "ItemsControl" &&
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ActiveQueueList");
|
||||||
|
Assert.NotNull(queueList);
|
||||||
|
|
||||||
|
var colDefs = queueList.Descendants()
|
||||||
|
.Where(e => e.Name.LocalName == "Grid.ColumnDefinitions" && e.Elements().Count() >= 6)
|
||||||
|
.FirstOrDefault();
|
||||||
|
Assert.NotNull(colDefs);
|
||||||
|
|
||||||
|
var widths = colDefs.Elements().Select(c => c.Attribute("Width")?.Value).ToList();
|
||||||
|
Assert.True(widths.Count >= 6, "ActiveQueueList 행 그리드는 최소 6개 컬럼(선택, 아이콘, 본문*, 크기, 상태, 삭제)이어야 합니다.");
|
||||||
|
|
||||||
|
// Column 3: SizeText width
|
||||||
|
var sizeWidth = widths[3];
|
||||||
|
Assert.True(int.TryParse(sizeWidth, out var sw) && sw <= 80,
|
||||||
|
$"SizeText 컬럼 폭은 80px 이하여야 메타데이터 컬럼이 오버플로되지 않습니다. 현재: {sizeWidth}");
|
||||||
|
|
||||||
|
// Column 4: StateText width
|
||||||
|
var stateWidth = widths[4];
|
||||||
|
Assert.True(int.TryParse(stateWidth, out var stw) && stw <= 90,
|
||||||
|
$"DisplayStateText 컬럼 폭은 90px 이하여야 메타데이터 컬럼이 오버플로되지 않습니다. 현재: {stateWidth}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inspector_PreviewArea_MustUse_DoubleBezelArchitecture()
|
||||||
|
{
|
||||||
|
// 미리보기 뷰포트는 단순 평면 박스가 아니라 고품격 machined 하드웨어 느낌의
|
||||||
|
// FsDoubleBezelShellStyle 및 FsDoubleBezelCoreStyle 이중 베젤(Doppelrand) 구조를 가져야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var previewArea = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PreviewImageArea");
|
||||||
|
Assert.NotNull(previewArea);
|
||||||
|
|
||||||
|
var hasShell = previewArea.Descendants().Any(e =>
|
||||||
|
e.Attribute("Style")?.Value.Contains("FsDoubleBezelShellStyle") == true);
|
||||||
|
var hasCore = previewArea.Descendants().Any(e =>
|
||||||
|
e.Attribute("Style")?.Value.Contains("FsDoubleBezelCoreStyle") == true);
|
||||||
|
|
||||||
|
Assert.True(hasShell && hasCore,
|
||||||
|
"PreviewImageArea는 고품격 Fluent 2 미학을 위해 FsDoubleBezelShellStyle 및 FsDoubleBezelCoreStyle 이중 베젤 구조를 갖추어야 합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_SearchBox_Width_MustLeaveAdequateRoomForFilterButtons()
|
||||||
|
{
|
||||||
|
// 1080p 및 컴팩트 뷰포트에서도 '전체' 및 카테고리 필터 버튼들이
|
||||||
|
// 검색창에 가려지거나 겹치지 않도록 검색창 너비는 180px 이하여야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var searchBox = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "TextBox" &&
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SearchBox");
|
||||||
|
Assert.NotNull(searchBox);
|
||||||
|
|
||||||
|
var toolbarGrid = searchBox.Ancestors()
|
||||||
|
.FirstOrDefault(e => e.Name.LocalName == "Grid" && e.Element(searchBox.Name.Namespace + "Grid.ColumnDefinitions") != null);
|
||||||
|
Assert.NotNull(toolbarGrid);
|
||||||
|
|
||||||
|
var searchBoxCol = toolbarGrid.Element(searchBox.Name.Namespace + "Grid.ColumnDefinitions")?
|
||||||
|
.Elements().FirstOrDefault();
|
||||||
|
Assert.NotNull(searchBoxCol);
|
||||||
|
|
||||||
|
var widthVal = searchBoxCol.Attribute("Width")?.Value;
|
||||||
|
Assert.True(widthVal == "*" || (int.TryParse(widthVal, out var w) && w <= 180),
|
||||||
|
$"SearchBox 컬럼 폭은 가변(*)이거나 180px 이하여야 필터 컨트롤이 잘리지 않습니다. 현재: {widthVal}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BatchActionBar_MustInclude_LiveCockpitSummary()
|
||||||
|
{
|
||||||
|
// BatchActionBar는 단순 버튼 나열이 아니라 큐의 총 파일 개수 및 총 데이터 용량을 한눈에 보여주는
|
||||||
|
// QueueSummaryCountText 및 QueueSummarySizeText 콕핏 텔레메트리 요소를 포함해야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var batchBar = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "BatchActionBar");
|
||||||
|
Assert.NotNull(batchBar);
|
||||||
|
|
||||||
|
var summaryCount = batchBar.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "QueueSummaryCountText");
|
||||||
|
var summarySize = batchBar.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "QueueSummarySizeText");
|
||||||
|
|
||||||
|
Assert.NotNull(summaryCount);
|
||||||
|
Assert.NotNull(summarySize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inspector_MustInclude_ConversionPipelineCard()
|
||||||
|
{
|
||||||
|
// 미리보기 패널(Inspector)은 원본과 대상 형식 간 변환 경로 및 예상 절감 효과를 즉시 시각화하는
|
||||||
|
// ConversionPipelineCard 컨테이너를 포함해야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var pipelineCard = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ConversionPipelineCard");
|
||||||
|
Assert.NotNull(pipelineCard);
|
||||||
|
|
||||||
|
var sourceText = pipelineCard.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PipelineSourceText");
|
||||||
|
var targetText = pipelineCard.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PipelineTargetText");
|
||||||
|
|
||||||
|
Assert.NotNull(sourceText);
|
||||||
|
Assert.NotNull(targetText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PastResults_MustInclude_TelemetryHeaderBar()
|
||||||
|
{
|
||||||
|
// 변환 기록 뷰(PastResultsContainer)는 단순 리스트 노출 전에 누적 변환 수량 및 총 절감량을 요약하고
|
||||||
|
// 빠른 내보내기 액션을 제공하는 PastResultsHeaderBar 툴바를 포함해야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var container = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PastResultsContainer");
|
||||||
|
Assert.NotNull(container);
|
||||||
|
|
||||||
|
var headerBar = container.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PastResultsHeaderBar");
|
||||||
|
Assert.NotNull(headerBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sidebar_OutputFormatHint_MustWrapText()
|
||||||
|
{
|
||||||
|
// 320px 좁은 사이드바 폭에서도 설명 문구가 '자동 필...'처럼 잘리지 않도록
|
||||||
|
// OutputFormatHint는 TextWrapping="Wrap"을 선언해야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var hint = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "OutputFormatHint");
|
||||||
|
Assert.NotNull(hint);
|
||||||
|
|
||||||
|
var textWrapping = hint.Attribute("TextWrapping")?.Value;
|
||||||
|
Assert.Equal("Wrap", textWrapping);
|
||||||
|
}
|
||||||
|
[Fact]
|
||||||
|
public void SearchBox_MustInclude_WatermarkPlaceholder_And_ClearButton()
|
||||||
|
{
|
||||||
|
// 검색창은 플레이스홀더 워터마크(SearchPlaceholderText)와 입력 내용 원클릭 지우기 버튼(SearchClearButton)을 포함해야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var placeholder = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SearchPlaceholderText");
|
||||||
|
var clearBtn = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SearchClearButton");
|
||||||
|
|
||||||
|
Assert.NotNull(placeholder);
|
||||||
|
Assert.NotNull(clearBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inspector_ActionButtons_MustBind_IsEnabled_To_Selection()
|
||||||
|
{
|
||||||
|
// 파일이 선택되지 않았을 때 무의미한 클릭을 방지하기 위해
|
||||||
|
// 미리보기 하단의 열기(PreviewOpenButton) 및 폴더보기(PreviewRevealButton) 버튼은 IsEnabled 바인딩 또는 제어를 가져야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var openBtn = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PreviewOpenButton");
|
||||||
|
var revealBtn = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PreviewRevealButton");
|
||||||
|
|
||||||
|
Assert.NotNull(openBtn);
|
||||||
|
Assert.NotNull(revealBtn);
|
||||||
|
|
||||||
|
var openEnabled = openBtn.Attribute("IsEnabled")?.Value;
|
||||||
|
var revealEnabled = revealBtn.Attribute("IsEnabled")?.Value;
|
||||||
|
|
||||||
|
Assert.NotNull(openEnabled);
|
||||||
|
Assert.NotNull(revealEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Window_InputBindings_MustInclude_SelectAll_And_Delete()
|
||||||
|
{
|
||||||
|
// Raycast / Linear 급 생산성을 위해 전체 선택(Ctrl+A) 및 선택 항목 삭제(Delete) 단축키가 Window.InputBindings에 등록되어야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var inputBindings = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "Window.InputBindings");
|
||||||
|
Assert.NotNull(inputBindings);
|
||||||
|
|
||||||
|
var keyBindings = inputBindings.Elements().Where(e => e.Name.LocalName == "KeyBinding").ToList();
|
||||||
|
bool hasCtrlA = keyBindings.Any(kb => kb.Attribute("Key")?.Value == "A" && kb.Attribute("Modifiers")?.Value == "Ctrl");
|
||||||
|
bool hasDelete = keyBindings.Any(kb => kb.Attribute("Key")?.Value == "Delete");
|
||||||
|
|
||||||
|
Assert.True(hasCtrlA, "Window.InputBindings에 Ctrl+A 단축키가 등록되어야 합니다.");
|
||||||
|
Assert.True(hasDelete, "Window.InputBindings에 Delete 단축키가 등록되어야 합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InspectorPanel_MustSpanFullHeight_And_NotLeaveTopDeadSpace()
|
||||||
|
{
|
||||||
|
// 미리보기 패널(Inspector Border)은 검색 툴바 상단에 빈 공간/어색한 마진을 남기지 않도록
|
||||||
|
// Grid.RowSpan="2" 및 Grid.Row="0"으로 메인 영역 전체 높이에 걸쳐 배치되어야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
var mainViewGrid = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Name.LocalName == "Grid" &&
|
||||||
|
e.Elements().Any(c => c.Name.LocalName == "Grid.ColumnDefinitions" &&
|
||||||
|
c.Elements().Any(cd => cd.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "InspectorColumn")));
|
||||||
|
Assert.NotNull(mainViewGrid);
|
||||||
|
|
||||||
|
var previewBorder = mainViewGrid.Elements().FirstOrDefault(e =>
|
||||||
|
e.Name.LocalName == "Border" &&
|
||||||
|
e.Attribute("Grid.Column")?.Value == "1");
|
||||||
|
Assert.NotNull(previewBorder);
|
||||||
|
|
||||||
|
var row = previewBorder.Attribute("Grid.Row")?.Value ?? "0";
|
||||||
|
var rowSpan = previewBorder.Attribute("Grid.RowSpan")?.Value;
|
||||||
|
|
||||||
|
Assert.Equal("0", row);
|
||||||
|
Assert.Equal("2", rowSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_Toolbar_MustHave_FlexibleSearch_And_CategoryFilterDropdown_Without_Overflow()
|
||||||
|
{
|
||||||
|
// 파일명 검색창과 카테고리 필터가 좁은 창 가로폭(360px)에서도 씹힘/겹침(Overflow Collision)이 발생하지 않도록
|
||||||
|
// 1) 5개 개별 가로 버튼 대신 단일 폴드아웃 드롭다운(CategoryFilterCombo)으로 정합되어야 하며
|
||||||
|
// 2) 검색창은 Width="*" 컬럼에 위치하여 가변 너비를 가져야 하고
|
||||||
|
// 3) CategoryFilterCombo는 VerticalAlignment="Center"를 준수해야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
// CategoryFilterCombo ComboBox 존재 검증
|
||||||
|
var combo = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Name.LocalName == "ComboBox" &&
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "CategoryFilterCombo");
|
||||||
|
Assert.NotNull(combo);
|
||||||
|
|
||||||
|
var vAlign = combo.Attribute("VerticalAlignment")?.Value;
|
||||||
|
Assert.Equal("Center", vAlign);
|
||||||
|
|
||||||
|
// 이전 5개 개별 가로 버튼(CommandParameter="All" 등)이 툴바에서 제거되어 드롭다운으로 대체되었는지 검증
|
||||||
|
var individualFilterButtons = doc.Descendants().Where(e =>
|
||||||
|
e.Name.LocalName == "Button" &&
|
||||||
|
e.Attribute("Command")?.Value?.Contains("FilterCategoryCommand") == true &&
|
||||||
|
e.Attribute("CommandParameter")?.Value is "All" or "Image" or "Document" or "Media" or "Data").ToList();
|
||||||
|
Assert.Empty(individualFilterButtons);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using System.Windows.Controls.Primitives;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using Everything2Everything.App.Views;
|
using Everything2Everything.App.Views;
|
||||||
using Everything2Everything.Core;
|
using Everything2Everything.Core;
|
||||||
|
using Everything2Everything.Core.Filters;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Everything2Everything.Tests;
|
namespace Everything2Everything.Tests;
|
||||||
|
|
@ -249,6 +250,248 @@ public class DesignAuditVisualTreeTests
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_WhenQueueIsEmpty_ShowsDefaultFormat_AndQualitySlider_AndAdvancedPanel_Visible()
|
||||||
|
{
|
||||||
|
// Zero-Void 원칙 및 점진적 공개 원칙: 큐가 비어있더라도 사용자가 변환 옵션과 품질 슬라이더를 사전에 확인하고 조작할 수 있도록
|
||||||
|
// 기본 출력 포맷(.jpg)과 품질 슬라이더, 해당 포맷의 상세 인코딩 서브패널이 Visible 이어야 한다.
|
||||||
|
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 formatCombo = (ComboBox)window.FindName("OutputFormatCombo");
|
||||||
|
var qualityPanel = (StackPanel)window.FindName("QualityPanel");
|
||||||
|
var advancedImagePanel = (StackPanel)window.FindName("AdvancedImagePanel");
|
||||||
|
|
||||||
|
Assert.NotNull(formatCombo);
|
||||||
|
Assert.NotNull(qualityPanel);
|
||||||
|
Assert.NotNull(advancedImagePanel);
|
||||||
|
|
||||||
|
// 큐가 비어있어도 지원 가능한 전체 포맷이 채워져 있어야 한다.
|
||||||
|
Assert.True(formatCombo.Items.Count > 0, "큐가 비어있어도 전체 지원 포맷이 표시되어야 합니다.");
|
||||||
|
// 기본 포맷(.jpg)에 맞춰 품질 슬라이더 패널이 표시되어야 한다.
|
||||||
|
Assert.Equal(Visibility.Visible, qualityPanel.Visibility);
|
||||||
|
// 기본 포맷에 맞춰 상세 설정 패널이 표시되어야 한다.
|
||||||
|
Assert.Equal(Visibility.Visible, advancedImagePanel.Visibility);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_FormatSwitching_UpdatesQuickPanels_And_AdvancedPanels_Correctly()
|
||||||
|
{
|
||||||
|
// 사용자 피드백 대응: 스마트 프리셋 직하단에서 각 미디어 형식(.mp4, .pdf, .mp3, .webp)에 따라
|
||||||
|
// 전용 퀵 컨트롤(품질/CRF/비트레이트/PDF압축)과 상세 폴드아웃 서브패널이 동적으로 즉시 표시되어야 한다.
|
||||||
|
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 formatCombo = (ComboBox)window.FindName("OutputFormatCombo");
|
||||||
|
var qualityPanel = (StackPanel)window.FindName("QualityPanel");
|
||||||
|
var videoQuickPanel = (StackPanel)window.FindName("VideoQuickPanel");
|
||||||
|
var audioQuickPanel = (StackPanel)window.FindName("AudioQuickPanel");
|
||||||
|
var pdfQuickPanel = (StackPanel)window.FindName("PdfQuickPanel");
|
||||||
|
var expander = (Expander)window.FindName("AdvancedOptionsExpander");
|
||||||
|
var advVideo = (StackPanel)window.FindName("AdvancedVideoPanel");
|
||||||
|
var advAudio = (StackPanel)window.FindName("AdvancedAudioPanel");
|
||||||
|
var advPdf = (StackPanel)window.FindName("AdvancedPdfPanel");
|
||||||
|
var advImage = (StackPanel)window.FindName("AdvancedImagePanel");
|
||||||
|
|
||||||
|
Assert.NotNull(formatCombo);
|
||||||
|
Assert.NotNull(qualityPanel);
|
||||||
|
Assert.NotNull(videoQuickPanel);
|
||||||
|
Assert.NotNull(audioQuickPanel);
|
||||||
|
Assert.NotNull(pdfQuickPanel);
|
||||||
|
Assert.NotNull(expander);
|
||||||
|
|
||||||
|
// 기본은 열려있어야 함 (사용자가 즉시 확인 가능)
|
||||||
|
Assert.True(expander.IsExpanded);
|
||||||
|
|
||||||
|
void SelectExtension(string ext)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < formatCombo.Items.Count; i++)
|
||||||
|
{
|
||||||
|
if (formatCombo.Items[i] is ComboBoxItem item && (string)item.Tag == ext)
|
||||||
|
{
|
||||||
|
formatCombo.SelectedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 영상 (.mp4) 전환 검증
|
||||||
|
SelectExtension(".mp4");
|
||||||
|
Assert.Equal(Visibility.Collapsed, qualityPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, videoQuickPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, advVideo.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, advAudio.Visibility);
|
||||||
|
Assert.Equal(Visibility.Collapsed, advPdf.Visibility);
|
||||||
|
|
||||||
|
// 2. PDF (.pdf) 전환 검증
|
||||||
|
SelectExtension(".pdf");
|
||||||
|
Assert.Equal(Visibility.Collapsed, qualityPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Collapsed, videoQuickPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, pdfQuickPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, advPdf.Visibility);
|
||||||
|
Assert.Equal(Visibility.Collapsed, advVideo.Visibility);
|
||||||
|
|
||||||
|
// 3. 오디오 (.mp3) 전환 검증
|
||||||
|
SelectExtension(".mp3");
|
||||||
|
Assert.Equal(Visibility.Collapsed, qualityPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, audioQuickPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, advAudio.Visibility);
|
||||||
|
Assert.Equal(Visibility.Collapsed, advVideo.Visibility);
|
||||||
|
|
||||||
|
// 4. 이미지 (.webp) 전환 검증
|
||||||
|
SelectExtension(".webp");
|
||||||
|
Assert.Equal(Visibility.Visible, qualityPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Collapsed, videoQuickPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Collapsed, audioQuickPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Collapsed, pdfQuickPanel.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, advImage.Visibility);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_RenderToBitmap_SavesVisualVerificationArtifact()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var dir = System.IO.Directory.GetCurrentDirectory();
|
||||||
|
while (dir != null && !System.IO.File.Exists(System.IO.Path.Combine(dir, "Everything2Everything.slnx")))
|
||||||
|
{
|
||||||
|
dir = System.IO.Directory.GetParent(dir)?.FullName;
|
||||||
|
}
|
||||||
|
var root = dir ?? throw new System.IO.DirectoryNotFoundException("솔루션 루트를 찾을 수 없습니다.");
|
||||||
|
|
||||||
|
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||||
|
var settings = new FakeSettingsStore();
|
||||||
|
var testFiles = new List<string>
|
||||||
|
{
|
||||||
|
System.IO.Path.Combine(root, "test_assets", "test_icon.png"),
|
||||||
|
System.IO.Path.Combine(root, "test_assets", "test_art.png")
|
||||||
|
};
|
||||||
|
|
||||||
|
var window = new MainWindow(engine, settings, testFiles);
|
||||||
|
window.ApplyTemplate();
|
||||||
|
var expander = (Expander)window.FindName("AdvancedOptionsExpander");
|
||||||
|
expander.ApplyTemplate();
|
||||||
|
|
||||||
|
var content = (UIElement)window.Content;
|
||||||
|
content.Measure(new Size(1280, 960));
|
||||||
|
content.Arrange(new Rect(0, 0, 1280, 960));
|
||||||
|
content.UpdateLayout();
|
||||||
|
|
||||||
|
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(1280, 960, 96, 96, PixelFormats.Pbgra32);
|
||||||
|
rtb.Render(content);
|
||||||
|
|
||||||
|
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||||
|
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(rtb));
|
||||||
|
|
||||||
|
var outDir = @"C:\Users\encep\.gemini\antigravity\brain\b58fd023-a52b-4f4b-aa23-e6df654aa1fb";
|
||||||
|
if (!System.IO.Directory.Exists(outDir)) System.IO.Directory.CreateDirectory(outDir);
|
||||||
|
var outPath = System.IO.Path.Combine(outDir, "rendered_toolbar_fixed.png");
|
||||||
|
using (var fs = new System.IO.FileStream(outPath, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.ReadWrite))
|
||||||
|
{
|
||||||
|
encoder.Save(fs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(System.IO.File.Exists(outPath));
|
||||||
|
Assert.True(new System.IO.FileInfo(outPath).Length > 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_CategoryFilterCombo_ChangesCategory_And_FiltersQueue()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||||
|
var settings = new FakeSettingsStore();
|
||||||
|
var window = new MainWindow(engine, settings);
|
||||||
|
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\test.png", FileName = "test.png" });
|
||||||
|
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\doc.pdf", FileName = "doc.pdf" });
|
||||||
|
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\clip.mp4", FileName = "clip.mp4" });
|
||||||
|
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\sheet.xlsx", FileName = "sheet.xlsx" });
|
||||||
|
|
||||||
|
var combo = (ComboBox)window.FindName("CategoryFilterCombo");
|
||||||
|
Assert.NotNull(combo);
|
||||||
|
Assert.Equal(5, combo.Items.Count);
|
||||||
|
|
||||||
|
// 0: 전체 (All)
|
||||||
|
Assert.Equal(FilterCategory.All, window.SelectedCategory);
|
||||||
|
|
||||||
|
// 1: 이미지 (Image)
|
||||||
|
combo.SelectedIndex = 1;
|
||||||
|
Assert.Equal(FilterCategory.Image, window.SelectedCategory);
|
||||||
|
|
||||||
|
var view = System.Windows.Data.CollectionViewSource.GetDefaultView(window.ActiveQueue);
|
||||||
|
var filteredItems = view.Cast<QueueItem>().ToList();
|
||||||
|
Assert.Single(filteredItems);
|
||||||
|
Assert.Equal("test.png", filteredItems[0].FileName);
|
||||||
|
|
||||||
|
// 0: 전체 복귀 (All)
|
||||||
|
combo.SelectedIndex = 0;
|
||||||
|
Assert.Equal(FilterCategory.All, window.SelectedCategory);
|
||||||
|
filteredItems = view.Cast<QueueItem>().ToList();
|
||||||
|
Assert.Equal(4, filteredItems.Count);
|
||||||
|
|
||||||
|
// 가상 레이아웃 검증 (너비 360px 환경에서도 DesiredSize가 정상 계산되는지)
|
||||||
|
var content = (UIElement)window.Content;
|
||||||
|
content.Measure(new Size(360, 600));
|
||||||
|
content.Arrange(new Rect(0, 0, 360, 600));
|
||||||
|
Assert.True(content.DesiredSize.Width > 0);
|
||||||
|
Assert.False(double.IsNaN(content.DesiredSize.Width));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdBannerControl_MeasureAndArrange_HasValidLayoutBounds()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var adService = new Everything2Everything.Core.Ads.AdService();
|
||||||
|
var vm = new Everything2Everything.App.ViewModels.AdViewModel(adService);
|
||||||
|
var banner = new AdBannerControl { DataContext = vm };
|
||||||
|
banner.Measure(new Size(800, 100));
|
||||||
|
banner.Arrange(new Rect(0, 0, 800, 100));
|
||||||
|
|
||||||
|
Assert.True(banner.DesiredSize.Width > 0);
|
||||||
|
Assert.True(banner.DesiredSize.Height > 0);
|
||||||
|
Assert.False(double.IsNaN(banner.DesiredSize.Width));
|
||||||
|
Assert.False(double.IsNaN(banner.DesiredSize.Height));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdLargeCardControl_MeasureAndArrange_HasValidLayoutBounds()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var adService = new Everything2Everything.Core.Ads.AdService();
|
||||||
|
var vm = new Everything2Everything.App.ViewModels.AdViewModel(adService);
|
||||||
|
var card = new AdLargeCardControl { DataContext = vm };
|
||||||
|
card.Measure(new Size(340, 300));
|
||||||
|
card.Arrange(new Rect(0, 0, 340, 300));
|
||||||
|
|
||||||
|
Assert.True(card.DesiredSize.Width > 0);
|
||||||
|
Assert.True(card.DesiredSize.Height > 0);
|
||||||
|
Assert.False(double.IsNaN(card.DesiredSize.Width));
|
||||||
|
Assert.False(double.IsNaN(card.DesiredSize.Height));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static IEnumerable<T> FindLogicalChildren<T>(object parent) where T : DependencyObject
|
private static IEnumerable<T> FindLogicalChildren<T>(object parent) where T : DependencyObject
|
||||||
{
|
{
|
||||||
if (parent is ContentControl cc && cc.Content != null)
|
if (parent is ContentControl cc && cc.Content != null)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Everything2Everything.App.ViewModels;
|
using Everything2Everything.App.ViewModels;
|
||||||
using Everything2Everything.Core;
|
using Everything2Everything.Core;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
using Everything2Everything.Core.Providers;
|
using Everything2Everything.Core.Providers;
|
||||||
using Everything2Everything.Core.Filters;
|
using Everything2Everything.Core.Filters;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -147,5 +148,56 @@ public class E2EUserScenarioTests
|
||||||
Assert.False(MediaConversionNegotiator.CanConvert(".xlsx", ".png"));
|
Assert.False(MediaConversionNegotiator.CanConvert(".xlsx", ".png"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("summarize", null, "요약")]
|
||||||
|
[InlineData("translate", "일본어", "일본어")]
|
||||||
|
[InlineData("proofread", null, "교정")]
|
||||||
|
public void Scenario8_AiQuickPanel_Options_BuildPrompt_FormatsCorrectlyForAllTasks(string task, string? targetLang, string expectedKeyword)
|
||||||
|
{
|
||||||
|
var options = new AiOptions { Task = task, TargetLanguage = targetLang };
|
||||||
|
var (system, user) = LlmProvider.BuildPrompt(options, "테스트 입력 문장");
|
||||||
|
Assert.Contains(expectedKeyword, system);
|
||||||
|
Assert.Equal("테스트 입력 문장", user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Scenario9_AiConversion_EndToEnd_SwitchboardResolution()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "E2E_AiConversion_" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var srcFile = Path.Combine(tempDir, "sample.md");
|
||||||
|
await File.WriteAllTextAsync(srcFile, "# Hello\nEverything2Everything test content.");
|
||||||
|
|
||||||
|
var store = new FakeSettingsStore();
|
||||||
|
store.Set("switchboard.endpoint", "http://192.168.0.225:8787");
|
||||||
|
var provider = new LlmProvider(store);
|
||||||
|
|
||||||
|
var options = new ConvertOptions
|
||||||
|
{
|
||||||
|
Ai = new AiOptions { Task = "summarize", Backend = "switchboard" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var (client, _) = provider.ResolveClientForTesting(options.Ai);
|
||||||
|
Assert.NotNull(client);
|
||||||
|
Assert.Equal("Switchboard Gateway", client.Name);
|
||||||
|
var sbClient = Assert.IsType<SwitchboardChatClient>(client);
|
||||||
|
Assert.Equal("http://192.168.0.225:8787", sbClient.Endpoint);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(tempDir))
|
||||||
|
Directory.Delete(tempDir, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
117
src/Everything2Everything.Tests/ExternalToolInstallerTests.cs
Normal file
117
src/Everything2Everything.Tests/ExternalToolInstallerTests.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
using Everything2Everything.App.ViewModels;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class ExternalToolInstallerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Catalog_CoversAllKnownTools_WithVerifiedWingetIds()
|
||||||
|
{
|
||||||
|
var byKey = ExternalToolCatalog.All.ToDictionary(t => t.Key, StringComparer.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(7, byKey.Count);
|
||||||
|
|
||||||
|
Assert.Equal("Gyan.FFmpeg", byKey["ffmpeg"].WingetId);
|
||||||
|
Assert.Equal("TheDocumentFoundation.LibreOffice", byKey["libreoffice"].WingetId);
|
||||||
|
Assert.Equal("JohnMacFarlane.Pandoc", byKey["pandoc"].WingetId);
|
||||||
|
Assert.Equal("ImageMagick.ImageMagick", byKey["imagemagick"].WingetId);
|
||||||
|
Assert.Equal("Google.AntigravityCLI", byKey["agy"].WingetId);
|
||||||
|
Assert.Equal("OpenAI.Codex", byKey["codex"].WingetId);
|
||||||
|
|
||||||
|
Assert.Equal(ExternalToolInstallKind.Manual, byKey["h2orestart"].Kind);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(byKey["h2orestart"].ManualNote));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InstallAsync_WingetTool_RunsSilentWinget_AndReportsInstalled()
|
||||||
|
{
|
||||||
|
var runner = new CapturingRunner();
|
||||||
|
var installer = new ExternalToolInstaller(runner);
|
||||||
|
var tool = WingetTool("test-tool", "Contoso.Demo", () => true);
|
||||||
|
|
||||||
|
var result = await installer.InstallAsync(tool, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("winget", runner.LastFileName);
|
||||||
|
Assert.Contains("--id Contoso.Demo", runner.LastArgs);
|
||||||
|
Assert.Contains("--silent", runner.LastArgs);
|
||||||
|
Assert.Contains("--accept-package-agreements", runner.LastArgs);
|
||||||
|
Assert.Contains("--accept-source-agreements", runner.LastArgs);
|
||||||
|
Assert.Contains("--disable-interactivity", runner.LastArgs);
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Null(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InstallAsync_WhenStillNotDetectedAfterRun_ReportsFailure()
|
||||||
|
{
|
||||||
|
var runner = new CapturingRunner { ExitCode = 1, Output = "installer failed" };
|
||||||
|
var installer = new ExternalToolInstaller(runner);
|
||||||
|
var tool = WingetTool("test-tool", "Contoso.Demo", () => false);
|
||||||
|
|
||||||
|
var result = await installer.InstallAsync(tool, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("실패", result.Message);
|
||||||
|
Assert.Equal(1, result.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InstallAsync_ManualKind_DoesNotRunAnything_ReturnsGuidance()
|
||||||
|
{
|
||||||
|
var runner = new CapturingRunner();
|
||||||
|
var installer = new ExternalToolInstaller(runner);
|
||||||
|
var tool = new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "manual",
|
||||||
|
DisplayName = "Manual",
|
||||||
|
Description = "d",
|
||||||
|
Kind = ExternalToolInstallKind.Manual,
|
||||||
|
ManualNote = "직접 추가하세요",
|
||||||
|
IsInstalled = () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await installer.InstallAsync(tool, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(runner.LastFileName);
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal("직접 추가하세요", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToolSetupViewModel_DefaultsToAllSelected_AndCoversCatalog()
|
||||||
|
{
|
||||||
|
var vm = new ToolSetupViewModel(
|
||||||
|
ExternalToolCatalog.All,
|
||||||
|
new ExternalToolInstaller(new CapturingRunner()));
|
||||||
|
|
||||||
|
Assert.Equal(ExternalToolCatalog.All.Count, vm.Tools.Count);
|
||||||
|
Assert.All(vm.Tools, t => Assert.True(t.IsSelected));
|
||||||
|
Assert.True(vm.SelectAll);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExternalToolDefinition WingetTool(string key, string id, Func<bool> installed) => new()
|
||||||
|
{
|
||||||
|
Key = key,
|
||||||
|
DisplayName = key,
|
||||||
|
Description = key,
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = id,
|
||||||
|
IsInstalled = installed,
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class CapturingRunner : IExternalCommandRunner
|
||||||
|
{
|
||||||
|
public string? LastFileName;
|
||||||
|
public string? LastArgs;
|
||||||
|
public int ExitCode { get; set; }
|
||||||
|
public string Output { get; set; } = "";
|
||||||
|
|
||||||
|
public Task<ExternalCommandResult> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct)
|
||||||
|
{
|
||||||
|
LastFileName = fileName;
|
||||||
|
LastArgs = arguments;
|
||||||
|
return Task.FromResult(new ExternalCommandResult(ExitCode, Output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,4 +60,24 @@ public class FileInspectorTests
|
||||||
var info = FileInspectorBuilder.Build(dummyPath);
|
var info = FileInspectorBuilder.Build(dummyPath);
|
||||||
Assert.Equal(expectedCategory, info.Category);
|
Assert.Equal(expectedCategory, info.Category);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(FilterCategory.All, "전체")]
|
||||||
|
[InlineData(FilterCategory.Image, "이미지")]
|
||||||
|
[InlineData(FilterCategory.Document, "문서")]
|
||||||
|
[InlineData(FilterCategory.Media, "미디어")]
|
||||||
|
[InlineData(FilterCategory.Data, "데이터")]
|
||||||
|
public void ToKoreanLabel_ReturnsRefinedKoreanCategory(FilterCategory category, string expectedLabel)
|
||||||
|
{
|
||||||
|
var label = category.ToKoreanLabel();
|
||||||
|
Assert.Equal(expectedLabel, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_DimensionsOrMeta_UsesKoreanCategory()
|
||||||
|
{
|
||||||
|
var dummyPath = "C:\\test\\sample.png";
|
||||||
|
var info = FileInspectorBuilder.Build(dummyPath);
|
||||||
|
Assert.Equal("PNG · 이미지", info.DimensionsOrMeta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ public class FormatPresetEngineTests
|
||||||
Assert.True(presets.Count >= 3);
|
Assert.True(presets.Count >= 3);
|
||||||
|
|
||||||
var q85 = presets.First(p => p.Title.Contains("웹 고화질"));
|
var q85 = presets.First(p => p.Title.Contains("웹 고화질"));
|
||||||
Assert.Contains("Quality 85", q85.SpecChips);
|
Assert.Contains("품질 85", q85.SpecChips);
|
||||||
|
|
||||||
var options = new OptionsViewModel();
|
var options = new OptionsViewModel();
|
||||||
q85.Apply(options);
|
q85.Apply(options);
|
||||||
|
|
@ -42,6 +42,23 @@ public class FormatPresetEngineTests
|
||||||
Assert.True(options.StripMetadata);
|
Assert.True(options.StripMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(".jpg", "품질 95")]
|
||||||
|
[InlineData(".webp", "품질 85")]
|
||||||
|
[InlineData(".avif", "품질 55")]
|
||||||
|
public void GetPresetsForExtension_ImagePresets_MustUseKoreanQualityChips(string ext, string expectedChip)
|
||||||
|
{
|
||||||
|
var presets = FormatPresetEngine.GetPresetsForExtension(ext);
|
||||||
|
var hasExpectedChip = presets.Any(p => p.SpecChips.Contains(expectedChip));
|
||||||
|
Assert.True(hasExpectedChip, $"{ext} 프리셋 스펙 칩에 한국어 '{expectedChip}'이 포함되어야 합니다.");
|
||||||
|
|
||||||
|
// 영어 Quality X 칩 금지
|
||||||
|
foreach (var p in presets)
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(p.SpecChips, c => c.StartsWith("Quality "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetPresetsForExtension_Pdf_ReturnsResolutionPresets()
|
public void GetPresetsForExtension_Pdf_ReturnsResolutionPresets()
|
||||||
{
|
{
|
||||||
|
|
@ -71,6 +88,30 @@ public class FormatPresetEngineTests
|
||||||
Assert.Equal("fast", options.VideoPreset);
|
Assert.Equal("fast", options.VideoPreset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(".jpg", 4)]
|
||||||
|
[InlineData(".png", 3)]
|
||||||
|
[InlineData(".webp", 4)]
|
||||||
|
[InlineData(".avif", 3)]
|
||||||
|
[InlineData(".pdf", 4)]
|
||||||
|
[InlineData(".mp4", 4)]
|
||||||
|
[InlineData(".mp3", 4)]
|
||||||
|
[InlineData(".gif", 2)]
|
||||||
|
public void GetPresetsForExtension_MajorFormats_ReturnRichPresetSuite(string ext, int minPresetCount)
|
||||||
|
{
|
||||||
|
var presets = FormatPresetEngine.GetPresetsForExtension(ext);
|
||||||
|
Assert.True(presets.Count >= minPresetCount,
|
||||||
|
$"{ext} 형식의 프리셋 개수가 너무 적습니다 (최소 {minPresetCount}개 이상 필요, 실제: {presets.Count}개).");
|
||||||
|
|
||||||
|
foreach (var p in presets)
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(p.Id), "프리셋 ID가 비어있습니다.");
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(p.Title), "프리셋 제목이 비어있습니다.");
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(p.Description), "프리셋 설명이 비어있습니다.");
|
||||||
|
Assert.True(p.SpecChips.Count >= 2, $"프리셋 [{p.Title}]의 스펙 칩 개수가 2개 미만입니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetPresetsForExtension_UnknownExtension_ReturnsFallbackPresets()
|
public void GetPresetsForExtension_UnknownExtension_ReturnsFallbackPresets()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ public class LlmProviderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task NoKey_IsNotReady()
|
public async Task NoKey_IsNotReady()
|
||||||
{
|
{
|
||||||
// 환경변수 키 또는 codex CLI(OAuth)가 있으면 AI가 활성화되므로 이 단언은 건너뜀
|
// 환경변수 키 또는 codex/agy CLI(OAuth) 또는 Switchboard Gateway가 있으면 AI가 활성화되므로 이 단언은 건너뜀
|
||||||
if (EnvHasKey() || ExternalToolDetector.IsCodexAvailable()) return;
|
if (EnvHasKey() || ExternalToolDetector.IsCodexAvailable() || ExternalToolDetector.IsAgyAvailable(out _) || ExternalToolDetector.IsSwitchboardGatewayAvailable(out _)) return;
|
||||||
var p = new LlmProvider(new FakeStore());
|
var p = new LlmProvider(new FakeStore());
|
||||||
var a = await p.CheckAvailabilityAsync();
|
var a = await p.CheckAvailabilityAsync();
|
||||||
Assert.False(a.IsReady);
|
Assert.False(a.IsReady);
|
||||||
|
|
|
||||||
|
|
@ -65,4 +65,26 @@ public class OptionsViewModelTests
|
||||||
Assert.Equal(NameCollision.Skip, o.OnCollision);
|
Assert.Equal(NameCollision.Skip, o.OnCollision);
|
||||||
Assert.False(o.VideoPreferGpu);
|
Assert.False(o.VideoPreferGpu);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OptionsViewModel_HasAdvancedProperties_And_DefaultExpandedIsTrue()
|
||||||
|
{
|
||||||
|
// 사용자 피드백 반영: 슬라이더와 상세 옵션을 바로 확인할 수 있도록 기본값으로 펼침(true) 상태여야 한다.
|
||||||
|
var vm = new OptionsViewModel();
|
||||||
|
Assert.True(vm.IsAdvancedExpanded);
|
||||||
|
Assert.Equal(0, vm.PdfCompressLevelIndex);
|
||||||
|
Assert.Equal(1, vm.PdfDpiIndex);
|
||||||
|
Assert.False(vm.ImageLossless);
|
||||||
|
Assert.False(vm.Progressive);
|
||||||
|
|
||||||
|
vm.ImageLossless = true;
|
||||||
|
vm.Progressive = true;
|
||||||
|
vm.PdfCompressLevelIndex = 1;
|
||||||
|
vm.PdfDpiIndex = 2;
|
||||||
|
|
||||||
|
var options = vm.ToConvertOptions();
|
||||||
|
Assert.True(options.Webp.Lossless);
|
||||||
|
Assert.True(options.Jpeg.Progressive);
|
||||||
|
Assert.Equal(300, options.PdfRender.Dpi);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Everything2Everything.Core;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class SwitchboardChatClientTests
|
||||||
|
{
|
||||||
|
private sealed class FakeStore : 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SwitchboardChatClient_HasCorrectNameAndEndpoint()
|
||||||
|
{
|
||||||
|
var client = new SwitchboardChatClient();
|
||||||
|
Assert.Equal("Switchboard Gateway", client.Name);
|
||||||
|
Assert.Equal("http://127.0.0.1:8787", client.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SwitchboardChatClient_CustomEndpoint_IsPreserved()
|
||||||
|
{
|
||||||
|
var client = new SwitchboardChatClient("http://192.168.1.100:8787/");
|
||||||
|
Assert.Equal("http://192.168.1.100:8787", client.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LlmProvider_ResolveClient_SelectsSwitchboardWhenRequested()
|
||||||
|
{
|
||||||
|
var store = new FakeStore();
|
||||||
|
store.Set("switchboard.endpoint", "http://127.0.0.1:8787");
|
||||||
|
var provider = new LlmProvider(store);
|
||||||
|
var (client, model) = provider.ResolveClientForTesting(new AiOptions { Backend = "switchboard" });
|
||||||
|
Assert.NotNull(client);
|
||||||
|
Assert.IsType<SwitchboardChatClient>(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckSwitchboardGatewayHealthAsync_ReturnsFalseForUnreachablePort()
|
||||||
|
{
|
||||||
|
var (available, _, _) = await ExternalToolDetector.CheckSwitchboardGatewayHealthAsync("http://127.0.0.1:59999");
|
||||||
|
Assert.False(available);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExternalToolDetector_IsSwitchboardGatewayAvailable_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var available = ExternalToolDetector.IsSwitchboardGatewayAvailable(out var ep);
|
||||||
|
Assert.Equal("http://127.0.0.1:8787", ep);
|
||||||
|
// Returns bool without hanging or throwing
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LlmProvider_CustomEndpoint_IsUsedWhenConfigured()
|
||||||
|
{
|
||||||
|
var store = new FakeStore();
|
||||||
|
store.Set("switchboard.endpoint", "http://192.168.0.25:8787");
|
||||||
|
var provider = new LlmProvider(store);
|
||||||
|
var (client, _) = provider.ResolveClientForTesting(new AiOptions { Backend = "switchboard" });
|
||||||
|
Assert.NotNull(client);
|
||||||
|
var sbClient = Assert.IsType<SwitchboardChatClient>(client);
|
||||||
|
Assert.Equal("http://192.168.0.25:8787", sbClient.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExternalToolDetector_IsSwitchboardGatewayAvailable_WithCustomEndpoint_PreservesEndpoint()
|
||||||
|
{
|
||||||
|
var available = ExternalToolDetector.IsSwitchboardGatewayAvailable(out var ep, "http://192.168.0.25:8787/");
|
||||||
|
Assert.Equal("http://192.168.0.25:8787", ep);
|
||||||
|
Assert.False(available);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +195,7 @@ public class UnifiedTitleBarTests
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(1280, 720, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
|
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(1280, 960, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
|
||||||
rtb.Render(content);
|
rtb.Render(content);
|
||||||
var enc = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
var enc = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||||
enc.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(rtb));
|
enc.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(rtb));
|
||||||
|
|
@ -313,6 +313,323 @@ public class UnifiedTitleBarTests
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_PopulatedActiveQueue_DisplaysBatchBarAndItems_AndEnablesLaunchButton()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||||
|
var store = new FakeSettingsStore();
|
||||||
|
var window = new MainWindow(engine, store);
|
||||||
|
|
||||||
|
// Add sample items to ActiveQueue
|
||||||
|
var q1 = new QueueItem
|
||||||
|
{
|
||||||
|
SourcePath = @"C:\Mock\nature_photo.jpg",
|
||||||
|
FileName = "nature_photo.jpg",
|
||||||
|
FormatLabel = "JPG",
|
||||||
|
FormatBrush = System.Windows.Media.Brushes.Coral,
|
||||||
|
SizeText = "4.2 MB",
|
||||||
|
MetaLine = "3840x2160 · sRGB · 24-bit",
|
||||||
|
SourceSizeBytes = 4_404_019,
|
||||||
|
SelectedOutputExtension = ".webp",
|
||||||
|
IsSelected = true
|
||||||
|
};
|
||||||
|
q1.SetState("queued");
|
||||||
|
|
||||||
|
var q2 = new QueueItem
|
||||||
|
{
|
||||||
|
SourcePath = @"C:\Mock\quarterly_report.pdf",
|
||||||
|
FileName = "quarterly_report.pdf",
|
||||||
|
FormatLabel = "PDF",
|
||||||
|
FormatBrush = System.Windows.Media.Brushes.IndianRed,
|
||||||
|
SizeText = "12.8 MB",
|
||||||
|
MetaLine = "32 페이지 · 텍스트/벡터 포함",
|
||||||
|
SourceSizeBytes = 13_421_772,
|
||||||
|
SelectedOutputExtension = ".pdf",
|
||||||
|
IsSelected = false
|
||||||
|
};
|
||||||
|
q2.SetState("50%");
|
||||||
|
|
||||||
|
var q3 = new QueueItem
|
||||||
|
{
|
||||||
|
SourcePath = @"C:\Mock\keynote_presentation.mp4",
|
||||||
|
FileName = "keynote_presentation.mp4",
|
||||||
|
FormatLabel = "MP4",
|
||||||
|
FormatBrush = System.Windows.Media.Brushes.CornflowerBlue,
|
||||||
|
SizeText = "156.4 MB",
|
||||||
|
MetaLine = "1080p60 · H.264 / AAC · 05:22",
|
||||||
|
SourceSizeBytes = 164_000_000,
|
||||||
|
SelectedOutputExtension = ".mp4",
|
||||||
|
IsSelected = false
|
||||||
|
};
|
||||||
|
q3.SetState("done");
|
||||||
|
|
||||||
|
window.ActiveQueue.Add(q1);
|
||||||
|
window.ActiveQueue.Add(q2);
|
||||||
|
window.ActiveQueue.Add(q3);
|
||||||
|
|
||||||
|
// Trigger visibility updates via reflection
|
||||||
|
var updateVisMethod = typeof(MainWindow).GetMethod("UpdateActiveQueueVisibility", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
updateVisMethod?.Invoke(window, null);
|
||||||
|
|
||||||
|
var updateBtnMethod = typeof(MainWindow).GetMethod("UpdateProcessQueueButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
updateBtnMethod?.Invoke(window, null);
|
||||||
|
|
||||||
|
var updateBadgesMethod = typeof(MainWindow).GetMethod("UpdateBadges", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
updateBadgesMethod?.Invoke(window, null);
|
||||||
|
|
||||||
|
var setPreviewMetaMethod = typeof(MainWindow).GetMethod("SetPreviewMeta", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
setPreviewMetaMethod?.Invoke(window, new object[] { q1.FileName, q1.SourcePath, q1.FormatLabel, q1.SizeText });
|
||||||
|
|
||||||
|
var showPreviewGlyphMethod = typeof(MainWindow).GetMethod("ShowPreviewGlyph", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
showPreviewGlyphMethod?.Invoke(window, new object[] { ".jpg", "선택된 파일: " + q1.FileName });
|
||||||
|
|
||||||
|
var activeQueueList = (ItemsControl)window.FindName("ActiveQueueList");
|
||||||
|
activeQueueList.ItemsSource = window.ActiveQueue;
|
||||||
|
activeQueueList.ApplyTemplate();
|
||||||
|
|
||||||
|
var content = (UIElement)window.Content;
|
||||||
|
content.Measure(new Size(1280, 960));
|
||||||
|
content.Arrange(new Rect(0, 0, 1280, 960));
|
||||||
|
content.UpdateLayout();
|
||||||
|
|
||||||
|
var dropZoneEmpty = (FrameworkElement)window.FindName("DropZoneEmpty");
|
||||||
|
var activeQueueScroll = (FrameworkElement)window.FindName("ActiveQueueScroll");
|
||||||
|
var batchActionBar = (FrameworkElement)window.FindName("BatchActionBar");
|
||||||
|
var processBtn = (System.Windows.Controls.Button)window.FindName("ProcessQueueButton");
|
||||||
|
|
||||||
|
Assert.NotNull(dropZoneEmpty);
|
||||||
|
Assert.NotNull(activeQueueScroll);
|
||||||
|
Assert.NotNull(batchActionBar);
|
||||||
|
Assert.NotNull(processBtn);
|
||||||
|
|
||||||
|
Assert.Equal(Visibility.Collapsed, dropZoneEmpty.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, activeQueueScroll.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, batchActionBar.Visibility);
|
||||||
|
|
||||||
|
Assert.Equal("변환 시작 (3개 파일)", processBtn.Content);
|
||||||
|
Assert.True(processBtn.IsEnabled);
|
||||||
|
|
||||||
|
// DisplayStateText check on items
|
||||||
|
Assert.Equal("대기 중", q1.DisplayStateText);
|
||||||
|
Assert.Equal("50%", q2.DisplayStateText);
|
||||||
|
Assert.Equal("변환 완료", q3.DisplayStateText);
|
||||||
|
|
||||||
|
var summaryCount = (TextBlock)window.FindName("QueueSummaryCountText");
|
||||||
|
var summarySize = (TextBlock)window.FindName("QueueSummarySizeText");
|
||||||
|
Assert.NotNull(summaryCount);
|
||||||
|
Assert.NotNull(summarySize);
|
||||||
|
Assert.Equal("총 3개 항목", summaryCount.Text);
|
||||||
|
|
||||||
|
var pipeSource = (TextBlock)window.FindName("PipelineSourceText");
|
||||||
|
var pipeTarget = (TextBlock)window.FindName("PipelineTargetText");
|
||||||
|
Assert.NotNull(pipeSource);
|
||||||
|
Assert.NotNull(pipeTarget);
|
||||||
|
Assert.Equal("JPG · 4.2 MB", pipeSource.Text);
|
||||||
|
Assert.Equal("JPG", pipeTarget.Text);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(1280, 960, 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_queue_populated.png";
|
||||||
|
using var fs = File.Create(outPath);
|
||||||
|
enc.Save(fs);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_PastResults_MustUseKorean_And_NotContainEnglishOutputOrSessionSavings()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var dg = new DateGroup("오늘 (9월 5일)");
|
||||||
|
Assert.Contains("절감", dg.SessionSavingsText);
|
||||||
|
Assert.DoesNotContain("Session Savings", dg.SessionSavingsText);
|
||||||
|
|
||||||
|
var entry = new HistoryEntry(
|
||||||
|
DateTime.Now,
|
||||||
|
@"C:\photos\sample.png",
|
||||||
|
"png",
|
||||||
|
1024 * 1024,
|
||||||
|
512 * 1024,
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
ConvertStatus.Success,
|
||||||
|
null,
|
||||||
|
new[] { @"C:\photos\sample.webp" });
|
||||||
|
|
||||||
|
var row = HistoryRow.From(entry);
|
||||||
|
Assert.Contains("개", row.MetaLine);
|
||||||
|
Assert.DoesNotContain("output(s)", row.MetaLine);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_TabCommand_DefaultFallback_MustBeActive()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||||
|
var store = new FakeSettingsStore();
|
||||||
|
var window = new MainWindow(engine, store);
|
||||||
|
var tabActiveBtn = (System.Windows.Controls.Primitives.ToggleButton)window.FindName("TabActiveBtn");
|
||||||
|
var tabPastBtn = (System.Windows.Controls.Primitives.ToggleButton)window.FindName("TabPastBtn");
|
||||||
|
var activeQueueView = (FrameworkElement)window.FindName("ActiveQueueView");
|
||||||
|
var pastResultsContainer = (FrameworkElement)window.FindName("PastResultsContainer");
|
||||||
|
|
||||||
|
// Switch to past first
|
||||||
|
window.TabCommand.Execute("Past");
|
||||||
|
Assert.False(tabActiveBtn.IsChecked);
|
||||||
|
Assert.True(tabPastBtn.IsChecked);
|
||||||
|
Assert.Equal(Visibility.Collapsed, activeQueueView.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, pastResultsContainer.Visibility);
|
||||||
|
|
||||||
|
// Execute with null or empty fallback
|
||||||
|
window.TabCommand.Execute(null);
|
||||||
|
Assert.True(tabActiveBtn.IsChecked);
|
||||||
|
Assert.False(tabPastBtn.IsChecked);
|
||||||
|
Assert.Equal(Visibility.Visible, activeQueueView.Visibility);
|
||||||
|
Assert.Equal(Visibility.Collapsed, pastResultsContainer.Visibility);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_PastResults_RendersPopulatedAndEmptyState()
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
content.UpdateLayout();
|
||||||
|
|
||||||
|
var updateBadgesMethod = typeof(MainWindow).GetMethod("UpdateBadges", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
|
||||||
|
// 1. Switch to Past tab when history is empty
|
||||||
|
window.PastResults.Clear();
|
||||||
|
updateBadgesMethod?.Invoke(window, null);
|
||||||
|
window.TabCommand.Execute("Past");
|
||||||
|
|
||||||
|
content.Measure(new Size(1280, 960));
|
||||||
|
content.Arrange(new Rect(0, 0, 1280, 960));
|
||||||
|
content.UpdateLayout();
|
||||||
|
|
||||||
|
var pastResultsContainer = (FrameworkElement)window.FindName("PastResultsContainer");
|
||||||
|
var pastResultsEmpty = (FrameworkElement)window.FindName("PastResultsEmpty");
|
||||||
|
var pastResultsView = (FrameworkElement)window.FindName("PastResultsView");
|
||||||
|
|
||||||
|
Assert.Equal(Visibility.Visible, pastResultsContainer.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, pastResultsEmpty.Visibility);
|
||||||
|
Assert.Equal(Visibility.Collapsed, pastResultsView.Visibility);
|
||||||
|
|
||||||
|
// Render empty state
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rtbEmpty = new System.Windows.Media.Imaging.RenderTargetBitmap(1280, 960, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
|
||||||
|
rtbEmpty.Render(content);
|
||||||
|
var enc = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||||
|
enc.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(rtbEmpty));
|
||||||
|
var outPath = @"C:\Users\encep\.gemini\antigravity\brain\b58fd023-a52b-4f4b-aa23-e6df654aa1fb\.tempmediaStorage\rendered_past_results_empty.png";
|
||||||
|
using var fs = File.Create(outPath);
|
||||||
|
enc.Save(fs);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
// 2. Populate Past Results
|
||||||
|
var dateGroup = new DateGroup("오늘 (9월 5일)");
|
||||||
|
var entry1 = new HistoryEntry(
|
||||||
|
DateTime.Now,
|
||||||
|
@"C:\demo\hero-graphic.png",
|
||||||
|
"png",
|
||||||
|
4_250_000,
|
||||||
|
820_000,
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
ConvertStatus.Success,
|
||||||
|
null,
|
||||||
|
new[] { @"C:\demo\hero-graphic.webp" });
|
||||||
|
var entry2 = new HistoryEntry(
|
||||||
|
DateTime.Now.AddMinutes(-12),
|
||||||
|
@"C:\demo\annual-report.docx",
|
||||||
|
"docx",
|
||||||
|
12_800_000,
|
||||||
|
3_150_000,
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
ConvertStatus.Success,
|
||||||
|
null,
|
||||||
|
new[] { @"C:\demo\annual-report.pdf" });
|
||||||
|
|
||||||
|
dateGroup.Add(HistoryRow.From(entry1));
|
||||||
|
dateGroup.Add(HistoryRow.From(entry2));
|
||||||
|
window.PastResults.Add(dateGroup);
|
||||||
|
|
||||||
|
updateBadgesMethod?.Invoke(window, null);
|
||||||
|
|
||||||
|
var pastRow = dateGroup.Entries.FirstOrDefault();
|
||||||
|
if (pastRow != null)
|
||||||
|
{
|
||||||
|
var setPreviewMetaMethod = typeof(MainWindow).GetMethod("SetPreviewMeta", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
setPreviewMetaMethod?.Invoke(window, new object[] { pastRow.FileName, pastRow.SourcePath, pastRow.FormatLabel, pastRow.SizeText });
|
||||||
|
var showPreviewGlyphMethod = typeof(MainWindow).GetMethod("ShowPreviewGlyph", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
showPreviewGlyphMethod?.Invoke(window, new object[] { ".png", "변환 완료: " + pastRow.FileName });
|
||||||
|
}
|
||||||
|
|
||||||
|
var pastResultsList = (ItemsControl)window.FindName("PastResultsList");
|
||||||
|
pastResultsList.ItemsSource = window.PastResults;
|
||||||
|
pastResultsList.ApplyTemplate();
|
||||||
|
|
||||||
|
content.Measure(new Size(1280, 960));
|
||||||
|
content.Arrange(new Rect(0, 0, 1280, 960));
|
||||||
|
content.UpdateLayout();
|
||||||
|
|
||||||
|
Assert.Equal(Visibility.Collapsed, pastResultsEmpty.Visibility);
|
||||||
|
Assert.Equal(Visibility.Visible, pastResultsView.Visibility);
|
||||||
|
|
||||||
|
var pastTotalCount = (TextBlock)window.FindName("PastTotalCountText");
|
||||||
|
Assert.NotNull(pastTotalCount);
|
||||||
|
Assert.Equal("2개 파일", pastTotalCount.Text);
|
||||||
|
|
||||||
|
// Render populated state
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rtbPopulated = new System.Windows.Media.Imaging.RenderTargetBitmap(1280, 960, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
|
||||||
|
rtbPopulated.Render(content);
|
||||||
|
var enc = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||||
|
enc.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(rtbPopulated));
|
||||||
|
var outPath = @"C:\Users\encep\.gemini\antigravity\brain\b58fd023-a52b-4f4b-aa23-e6df654aa1fb\.tempmediaStorage\rendered_past_results.png";
|
||||||
|
using var fs = File.Create(outPath);
|
||||||
|
enc.Save(fs);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_EnsureHandle_CreatesValidHwnd()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||||
|
var store = new FakeSettingsStore();
|
||||||
|
var window = new MainWindow(engine, store);
|
||||||
|
var helper = new System.Windows.Interop.WindowInteropHelper(window);
|
||||||
|
var hwnd = helper.EnsureHandle();
|
||||||
|
Assert.NotEqual(IntPtr.Zero, hwnd);
|
||||||
|
window.Close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SettingsWindow_TitleBar_MustDisplayProperlyAlignedHeaderAndCloseButton()
|
public void SettingsWindow_TitleBar_MustDisplayProperlyAlignedHeaderAndCloseButton()
|
||||||
{
|
{
|
||||||
|
|
@ -355,6 +672,18 @@ public class UnifiedTitleBarTests
|
||||||
var closeCenterY = closeBtn.TransformToAncestor(titleBar).Transform(new Point(0, closeBtn.ActualHeight / 2.0)).Y;
|
var closeCenterY = closeBtn.TransformToAncestor(titleBar).Transform(new Point(0, closeBtn.ActualHeight / 2.0)).Y;
|
||||||
Assert.True(Math.Abs(textCenterY - closeCenterY) <= 2.0,
|
Assert.True(Math.Abs(textCenterY - closeCenterY) <= 2.0,
|
||||||
$"Title text CenterY ({textCenterY:F1}px) and CloseButton CenterY ({closeCenterY:F1}px) must match within 2px.");
|
$"Title text CenterY ({textCenterY:F1}px) and CloseButton CenterY ({closeCenterY:F1}px) must match within 2px.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(560, 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_settings.png";
|
||||||
|
using var fs = File.Create(outPath);
|
||||||
|
enc.Save(fs);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
50
tools/Check-Window.ps1
Normal file
50
tools/Check-Window.ps1
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, System.Drawing, System.Windows.Forms
|
||||||
|
|
||||||
|
$proc = Get-Process -Name "Everything2Everything" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
if (-not $proc) {
|
||||||
|
Write-Host "Everything2Everything 프로세스가 없습니다."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Process ID: $($proc.Id), MainWindowHandle: $($proc.MainWindowHandle), Title: '$($proc.MainWindowTitle)'"
|
||||||
|
|
||||||
|
# Find UI Automation element
|
||||||
|
$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ProcessIdProperty, $proc.Id)
|
||||||
|
$windowEl = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||||
|
|
||||||
|
if (-not $windowEl) {
|
||||||
|
# Try descendants
|
||||||
|
$windowEl = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($windowEl) {
|
||||||
|
Write-Host "UI Automation Window Name: '$($windowEl.Current.Name)'"
|
||||||
|
Write-Host "BoundingRectangle: $($windowEl.Current.BoundingRectangle)"
|
||||||
|
|
||||||
|
# Look for our specific controls:
|
||||||
|
$controls = @("SmartPresetCombo", "QualitySlider", "VideoCrfSlider", "AudioBitrateQuickCombo", "PdfCompressQuickCombo", "AdvancedOptionsExpander", "OutputFormatCombo")
|
||||||
|
foreach ($cid in $controls) {
|
||||||
|
$cCond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::AutomationIdProperty, $cid)
|
||||||
|
$el = $windowEl.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $cCond)
|
||||||
|
if ($el) {
|
||||||
|
Write-Host " FOUND Control [$cid]: Type=$($el.Current.ControlType.ProgrammaticName), Name='$($el.Current.Name)', IsOffscreen=$($el.Current.IsOffscreen)"
|
||||||
|
} else {
|
||||||
|
Write-Host " MISSING Control [$cid]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Take screenshot of the window
|
||||||
|
$rect = $windowEl.Current.BoundingRectangle
|
||||||
|
if ($rect.Width -gt 50 -and $rect.Height -gt 50) {
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap ([int]$rect.Width), ([int]$rect.Height)
|
||||||
|
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$gfx.CopyFromScreen([int]$rect.Left, [int]$rect.Top, 0, 0, (New-Object System.Drawing.Size([int]$rect.Width, [int]$rect.Height)))
|
||||||
|
$shotPath = "C:\Users\encep\.gemini\antigravity\brain\a5abaf02-dd4b-45f7-8890-144e9da36bcc\window_screenshot.png"
|
||||||
|
$bmp.Save($shotPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$gfx.Dispose()
|
||||||
|
$bmp.Dispose()
|
||||||
|
Write-Host "SCREENSHOT_SAVED: $shotPath"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "AutomationElement를 찾지 못했습니다."
|
||||||
|
}
|
||||||
25
tools/Dump-Tree.ps1
Normal file
25
tools/Dump-Tree.ps1
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
|
||||||
|
|
||||||
|
$proc = Get-Process -Name "Everything2Everything" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
if (-not $proc) {
|
||||||
|
Write-Host "프로세스를 먼저 실행합니다..."
|
||||||
|
$file1 = "d:\workspace\Everything2Everthing\test_assets\test_icon.png"
|
||||||
|
$proc = Start-Process "d:\workspace\Everything2Everthing\src\Everything2Everything.App\bin\Debug\net9.0-windows10.0.19041.0\Everything2Everything.exe" -ArgumentList "`"$file1`"" -PassThru
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
}
|
||||||
|
|
||||||
|
$p = Get-Process -Id $proc.Id
|
||||||
|
Write-Host "Process Id: $($p.Id), Handle: $($p.MainWindowHandle)"
|
||||||
|
|
||||||
|
$windowEl = [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr]$p.MainWindowHandle)
|
||||||
|
$all = $windowEl.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition)
|
||||||
|
|
||||||
|
Write-Host "총 UIA 컨트롤 수: $($all.Count)"
|
||||||
|
foreach ($el in $all) {
|
||||||
|
$id = $el.Current.AutomationId
|
||||||
|
$name = $el.Current.Name
|
||||||
|
$type = $el.Current.ControlType.ProgrammaticName
|
||||||
|
if ($id -or $name) {
|
||||||
|
Write-Host "[$type] Id='$id' Name='$name'"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
tools/Dump-UiaControls.ps1
Normal file
31
tools/Dump-UiaControls.ps1
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
|
||||||
|
|
||||||
|
$proc = Get-Process -Name "Everything2Everything" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
if (-not $proc) {
|
||||||
|
Write-Host "프로세스가 없습니다."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "발견된 프로세스 ID: $($proc.Id)"
|
||||||
|
|
||||||
|
$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ProcessIdProperty, $proc.Id)
|
||||||
|
$windowEl = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||||
|
|
||||||
|
if (-not $windowEl) {
|
||||||
|
Write-Host "루트 엘리먼트 아래에서 윈도우를 찾을 수 없습니다."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "윈도우 제목: '$($windowEl.Current.Name)', NativeHandle: $($windowEl.Current.NativeWindowHandle)"
|
||||||
|
|
||||||
|
$all = $windowEl.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition)
|
||||||
|
Write-Host "총 발견된 UI 컨트롤 수: $($all.Count)"
|
||||||
|
|
||||||
|
foreach ($el in $all) {
|
||||||
|
$autoId = $el.Current.AutomationId
|
||||||
|
$name = $el.Current.Name
|
||||||
|
$type = $el.Current.ControlType.ProgrammaticName
|
||||||
|
if ($autoId -or $name) {
|
||||||
|
Write-Host "Type: $type | Id: '$autoId' | Name: '$name'"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
tools/Render-WindowScreenshot.ps1
Normal file
49
tools/Render-WindowScreenshot.ps1
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase
|
||||||
|
|
||||||
|
$binDir = "D:\workspace\Everything2Everthing\src\Everything2Everything.App\bin\Debug\net9.0-windows10.0.19041.0"
|
||||||
|
[System.Reflection.Assembly]::LoadFrom((Join-Path $binDir "Everything2Everything.Core.dll")) | Out-Null
|
||||||
|
[System.Reflection.Assembly]::LoadFrom((Join-Path $binDir "Everything2Everything.dll")) | Out-Null
|
||||||
|
|
||||||
|
$testAssets = "D:\workspace\Everything2Everthing\test_assets"
|
||||||
|
$testFiles = @(Join-Path $testAssets "test_icon.png", Join-Path $testAssets "test_art.png")
|
||||||
|
|
||||||
|
$thread = New-Object System.Threading.Thread([System.Threading.ThreadStart]{
|
||||||
|
try {
|
||||||
|
if ($null -eq [System.Windows.Application]::Current) {
|
||||||
|
$app = New-Object System.Windows.Application
|
||||||
|
}
|
||||||
|
|
||||||
|
$engine = [Everything2Everything.Core.Everything2EverythingBootstrap]::CreateDefault()
|
||||||
|
|
||||||
|
# Simple settings store via dynamic object or reflection
|
||||||
|
$settingsType = [System.Type]::GetType("Everything2Everything.Core.ISettingsStore, Everything2Everything.Core")
|
||||||
|
# Use existing memory settings store if available
|
||||||
|
$servicesField = $engine.GetType().GetProperty("Providers")
|
||||||
|
$store = New-Object Everything2Everything.Core.Settings.MemorySettingsStore
|
||||||
|
|
||||||
|
$win = New-Object Everything2Everything.App.Views.MainWindow($engine, $store, [string[]]$testFiles)
|
||||||
|
|
||||||
|
$size = New-Object System.Windows.Size(1280, 960)
|
||||||
|
$win.Measure($size)
|
||||||
|
$win.Arrange((New-Object System.Windows.Rect(0, 0, 1280, 960)))
|
||||||
|
$win.UpdateLayout()
|
||||||
|
|
||||||
|
$rtb = New-Object System.Windows.Media.Imaging.RenderTargetBitmap(1280, 960, 96, 96, [System.Windows.Media.PixelFormats]::Pbgra32)
|
||||||
|
$rtb.Render($win)
|
||||||
|
|
||||||
|
$encoder = New-Object System.Windows.Media.Imaging.PngBitmapEncoder
|
||||||
|
$encoder.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($rtb))
|
||||||
|
|
||||||
|
$outPath = "C:\Users\encep\.gemini\antigravity\brain\a5abaf02-dd4b-45f7-8890-144e9da36bcc\app_rendered_preview.png"
|
||||||
|
$fs = [System.IO.File]::OpenWrite($outPath)
|
||||||
|
$encoder.Save($fs)
|
||||||
|
$fs.Close()
|
||||||
|
Write-Host "RENDER_SUCCESS: $outPath"
|
||||||
|
} catch {
|
||||||
|
Write-Error $_.Exception.ToString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$thread.SetApartmentState([System.Threading.ApartmentState]::STA)
|
||||||
|
$thread.Start()
|
||||||
|
$thread.Join()
|
||||||
196
tools/Test-HeadfulE2E.ps1
Normal file
196
tools/Test-HeadfulE2E.ps1
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
# tools/Test-HeadfulE2E.ps1
|
||||||
|
# Everything2Everything Headful E2E 시각적 상호작용 및 실제 변환 검증 스크립트
|
||||||
|
# WMI 분리 런처(agy-gui-launch.ps1) 경유 규약 준수
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$Release
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$RootDir = Split-Path -Parent $ScriptDir
|
||||||
|
$Config = if ($Release) { "Release" } else { "Debug" }
|
||||||
|
$exePath = Join-Path $RootDir "src/Everything2Everything.App/bin/$Config/net9.0-windows10.0.19041.0/Everything2Everything.exe"
|
||||||
|
|
||||||
|
Write-Host "==========================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " Everything2Everything Headful E2E 인터랙션 검증" -ForegroundColor Cyan
|
||||||
|
Write-Host " 실행 바이너리: $exePath" -ForegroundColor Cyan
|
||||||
|
Write-Host "==========================================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 0. 기존 프로세스 종료
|
||||||
|
Stop-Process -Name "Everything2Everything" -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
|
# 테스트 파일 준비
|
||||||
|
$testDir = Join-Path $RootDir "test_assets"
|
||||||
|
$file1 = Join-Path $testDir "test_icon.png"
|
||||||
|
$file2 = Join-Path $testDir "test_art.png"
|
||||||
|
|
||||||
|
# 기존 변환 출력 디렉터리 정리
|
||||||
|
Remove-Item (Join-Path $testDir "*_converted*") -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item (Join-Path $testDir "*_webp*") -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item (Join-Path $testDir "*_pdf*") -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# 1. WMI 런처를 통해 사용자 인터랙티브 데스크톱 세션에 실행
|
||||||
|
Write-Host "`n[Step 1] WMI 런처를 통해 Headful 창 실행 중 (입력 파일 2개 탑재)..." -ForegroundColor Yellow
|
||||||
|
$argsStr = "`"$file1`" `"$file2`""
|
||||||
|
|
||||||
|
& "C:\Users\encep\.gemini\agy-gui-launch.ps1" -ExePath $exePath -Arguments $argsStr -ProcessName "Everything2Everything" -WaitSeconds 60
|
||||||
|
|
||||||
|
$p = Get-Process -Name "Everything2Everything" -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1
|
||||||
|
if (-not $p) {
|
||||||
|
Write-Error "MainWindowHandle이 0이 아닌 Everything2Everything 프로세스를 찾지 못했습니다."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = $p.MainWindowHandle
|
||||||
|
Write-Host "✅ Headful 창 활성화 완료! (PID: $($p.Id), Handle: $handle, Title: '$($p.MainWindowTitle)')" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 2. UI Automation 로드
|
||||||
|
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
|
||||||
|
$windowEl = [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr]$handle)
|
||||||
|
if (-not $windowEl) {
|
||||||
|
Write-Error "AutomationElement를 찾을 수 없습니다."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "✅ UI Automation 연결 성공: $($windowEl.Current.Name)" -ForegroundColor Green
|
||||||
|
|
||||||
|
function Find-ElementById([string]$autoId) {
|
||||||
|
$cond = New-Object System.Windows.Automation.PropertyCondition ([System.Windows.Automation.AutomationElement]::AutomationIdProperty), $autoId
|
||||||
|
return $windowEl.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-ElementByName([string]$name) {
|
||||||
|
$cond = New-Object System.Windows.Automation.PropertyCondition ([System.Windows.Automation.AutomationElement]::NameProperty), $name
|
||||||
|
return $windowEl.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. 큐 및 뱃지 상태 탐색
|
||||||
|
Write-Host "`n[Step 2] 큐 및 컨트롤 실시간 탐색..." -ForegroundColor Yellow
|
||||||
|
$badge = Find-ElementById "TabActiveBadge"
|
||||||
|
if ($badge) {
|
||||||
|
Write-Host " - Active Queue 뱃지: '$($badge.Current.Name)'" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. 상세 설정 폴드아웃(AdvancedOptionsExpander) 클릭
|
||||||
|
Write-Host "`n[Step 3] 상세 설정 폴드아웃(AdvancedOptionsExpander) 펼치기 클릭..." -ForegroundColor Yellow
|
||||||
|
$expander = Find-ElementById "AdvancedOptionsExpander"
|
||||||
|
if ($expander) {
|
||||||
|
Write-Host " - AdvancedOptionsExpander 컨트롤 발견!" -ForegroundColor Gray
|
||||||
|
$expPattern = $expander.GetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern) -as [System.Windows.Automation.ExpandCollapsePattern]
|
||||||
|
if ($expPattern) {
|
||||||
|
$expPattern.Expand()
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
Write-Host "✅ 상세 설정 폴드아웃 펼침 성공! (상태: $($expPattern.Current.ExpandCollapseState))" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "AdvancedOptionsExpander ID를 찾지 못했습니다."
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4-1. 고급 이미지 인코딩 옵션(ImageLosslessCheck) 클릭 및 토글
|
||||||
|
Write-Host "`n[Step 3-1] 이미지 무손실(Lossless) 옵션 체크박스 클릭..." -ForegroundColor Yellow
|
||||||
|
$losslessCheck = Find-ElementById "ImageLosslessCheck"
|
||||||
|
if ($losslessCheck) {
|
||||||
|
Write-Host " - ImageLosslessCheck 컨트롤 발견!" -ForegroundColor Gray
|
||||||
|
$togglePattern = $losslessCheck.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern) -as [System.Windows.Automation.TogglePattern]
|
||||||
|
if ($togglePattern) {
|
||||||
|
$togglePattern.Toggle()
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
Write-Host "✅ 무손실(Lossless) 체크박스 토글 완료! (상태: $($togglePattern.Current.ToggleState))" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host " ℹ️ 현재 포맷 패널에 따라 ImageLosslessCheck 표시 여부 확인됨." -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4-2. 윈도우 스크린샷 캡처 (Headful UI 동작 증빙)
|
||||||
|
try {
|
||||||
|
Add-Type -AssemblyName System.Drawing, System.Windows.Forms
|
||||||
|
$rect = $windowEl.Current.BoundingRectangle
|
||||||
|
if ($rect.Width -gt 0 -and $rect.Height -gt 0) {
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap ([int]$rect.Width), ([int]$rect.Height)
|
||||||
|
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$gfx.CopyFromScreen([int]$rect.Left, [int]$rect.Top, 0, 0, (New-Object System.Drawing.Size([int]$rect.Width, [int]$rect.Height)))
|
||||||
|
$shotPath = "C:\Users\encep\.gemini\antigravity\brain\a5abaf02-dd4b-45f7-8890-144e9da36bcc\e2e_headful_ui.png"
|
||||||
|
$bmp.Save($shotPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$gfx.Dispose()
|
||||||
|
$bmp.Dispose()
|
||||||
|
Write-Host "📸 Headful UI 창 스크린샷 캡처 성공: $shotPath" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Warning "스크린샷 캡처 중 경고 발생: $($_.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. 변환 시작 버튼 클릭
|
||||||
|
Write-Host "`n[Step 4] '대기열 일괄 변환 시작' 버튼 클릭 트리거..." -ForegroundColor Yellow
|
||||||
|
$convertBtn = Find-ElementById "ProcessQueueButton"
|
||||||
|
if (-not $convertBtn) {
|
||||||
|
$convertBtn = $windowEl.FindFirst([System.Windows.Automation.TreeScope]::Descendants,
|
||||||
|
(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, [System.Windows.Automation.ControlType]::Button)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($convertBtn) {
|
||||||
|
Write-Host " - 변환 버튼 발견: '$($convertBtn.Current.Name)'" -ForegroundColor Gray
|
||||||
|
$btnPattern = $convertBtn.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) -as [System.Windows.Automation.InvokePattern]
|
||||||
|
if ($btnPattern) {
|
||||||
|
$btnPattern.Invoke()
|
||||||
|
Write-Host "✅ '대기열 일괄 변환 시작' 버튼 클릭 완료! 변환 엔진 가동!" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Error "변환 버튼을 찾지 못했습니다."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 6. 변환 완료 대기 및 실시간 관측
|
||||||
|
Write-Host "`n[Step 5] 실시간 변환 진행 및 완료 대기 중..." -ForegroundColor Yellow
|
||||||
|
$completed = $false
|
||||||
|
for ($i = 0; $i -lt 30; $i++) {
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
$convertedFiles = Get-ChildItem (Join-Path $testDir "*") -Recurse -File | Where-Object {
|
||||||
|
$_.DirectoryName -ne $testDir -and $_.LastWriteTime -gt (Get-Date).AddMinutes(-2)
|
||||||
|
}
|
||||||
|
if ($convertedFiles.Count -ge 2) {
|
||||||
|
$completed = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Write-Host " ... 변환 진행 중 ($([math]::Round($i * 0.6, 1))초 경과)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
# 7. 실제 결과 파일 검증
|
||||||
|
Write-Host "`n[Step 6] 실제 디스크 출력 결과 파일 전수 검증:" -ForegroundColor Yellow
|
||||||
|
$allOutputs = Get-ChildItem (Join-Path $testDir "*") -Recurse -File | Where-Object {
|
||||||
|
$_.DirectoryName -ne $testDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allOutputs.Count -eq 0) {
|
||||||
|
Write-Error "❌ 변환 결과 파일이 생성되지 않았습니다!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($out in $allOutputs) {
|
||||||
|
Write-Host " 📄 생성된 파일: $($out.FullName)" -ForegroundColor Cyan
|
||||||
|
Write-Host " 크기: $([math]::Round($out.Length / 1KB, 2)) KB ($($out.Length) bytes)" -ForegroundColor Gray
|
||||||
|
Write-Host " 생성 시간: $($out.LastWriteTime)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# 최종 화면 상태 스크린샷 캡처 (변환 완료 상태)
|
||||||
|
try {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$rect = $windowEl.Current.BoundingRectangle
|
||||||
|
if ($rect.Width -gt 0 -and $rect.Height -gt 0) {
|
||||||
|
$bmp2 = New-Object System.Drawing.Bitmap ([int]$rect.Width), ([int]$rect.Height)
|
||||||
|
$gfx2 = [System.Drawing.Graphics]::FromImage($bmp2)
|
||||||
|
$gfx2.CopyFromScreen([int]$rect.Left, [int]$rect.Top, 0, 0, (New-Object System.Drawing.Size([int]$rect.Width, [int]$rect.Height)))
|
||||||
|
$doneShotPath = "C:\Users\encep\.gemini\antigravity\brain\a5abaf02-dd4b-45f7-8890-144e9da36bcc\e2e_headful_done.png"
|
||||||
|
$bmp2.Save($doneShotPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$gfx2.Dispose()
|
||||||
|
$bmp2.Dispose()
|
||||||
|
Write-Host "📸 변환 완료 Headful UI 스크린샷 저장 완료: $doneShotPath" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Warning "완료 스크린샷 캡처 중 경고 발생: $($_.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n🎉 Headful E2E 실제 클릭 및 변환 전 과정 테스트 성공!" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 8. 테스트 종료 후 프로세스 정리
|
||||||
|
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||||
9
tools/test_launch.ps1
Normal file
9
tools/test_launch.ps1
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
$exe = "D:\workspace\Everything2Everthing\src\Everything2Everything.App\bin\Debug\net9.0-windows10.0.19041.0\Everything2Everything.exe"
|
||||||
|
$testFile = "D:\workspace\Everything2Everthing\test_assets\test_icon.png"
|
||||||
|
Write-Host "Starting: $exe"
|
||||||
|
$proc = Start-Process -FilePath $exe -ArgumentList "`"$testFile`"" -PassThru
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
Get-Process -Id $proc.Id | Format-List Id, ProcessName, MainWindowHandle, MainWindowTitle, Responding
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
Stop-Process -Id $proc.Id -Force
|
||||||
|
Write-Host "Done"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue