refactor(P1): 엔진 실행경로 특성화(골든마스터) 안전망 구축
코드 변경 전 회귀 머지 게이트 확보 — 프로덕션 코드 0줄 변경.
적대적 리뷰 지적("엔진 e2e 테스트 0개", "메타-only는 alpha/압축 회귀에 눈멈") 반영.
- EngineCharacterizationTests: ConvertOneAsync/ConvertManyAsync/ExecuteChainAsync/CombineAsync
실파일 e2e 10 시나리오 — png→jpg(1홉)/svg→jpg(멀티홉)/PDF→페이지별/중간 다중산출물 거부
(line200-205)/이미지N장→TIFF·PDF 결합/멀티홉·단일홉 Skip 존중/동일포맷 Skip/png 재압축
- 단순 파일존재 체크 금지 → 구조메타(치수·HasAlpha·포맷·프레임수·압축) + 내용지문(평균색 우세) 박제
- RoutingMatrixCharacterizationTests: FindBestPath/ReachableOutputs 불변 라우팅(13 경로 홉수 +
self-edge + AvoidLossy 래스터 회피 + UI 추천출력 세트) 박제
- docs/ssot/REFACTOR_SMOKE_CHECKLIST.md: 진입점 수동 스모크(DI·MVVM 전환 직후 강제)
발견된 잠재 부채: combine→TIFF가 Tiff.Compression "lzw" define에도 NoCompression으로 저장(미적용).
동작 보존 원칙에 따라 현재 동작을 박제하고 P3 combine 추출 시 정책 통일로 다룸.
38 → 65개 테스트 전부 그린, 빌드 0경고/0오류.
This commit is contained in:
parent
f36ee299f4
commit
6b524662a4
3 changed files with 450 additions and 0 deletions
34
docs/ssot/REFACTOR_SMOKE_CHECKLIST.md
Normal file
34
docs/ssot/REFACTOR_SMOKE_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# 리팩토링 진입점 수동 스모크 체크리스트
|
||||
|
||||
> 자동 테스트로 덮이지 않는 진입점/UI 경로의 회귀 방어선. **DI 컴포지션 루트 전환(P2), MVVM 분해(P5) 직후 각 1회씩 강제 실행.**
|
||||
> 적대적 리뷰 지적: `App.xaml.cs`는 진입점이 5개이고 `Environment.Exit` 즉시 종료·MSIX 쓰기폴더 제약이 있어 Host 라이프사이클 전환 시 충돌 지점이 많은데 자동 테스트가 0개다.
|
||||
|
||||
## 진입점 (App.xaml.cs / CliRouter)
|
||||
|
||||
- [ ] **ShowMain** — 인자 없이 실행 → 메인 창이 정상 표시되고 큐가 비어 있음
|
||||
- [ ] **Quick (빠른 변환)** — 셸 컨텍스트 메뉴/CLI로 파일 1개 빠른 변환 → QuickProgressWindow가 제목 동적+% 갱신하며 뜨고, 완료 후 정상 종료(Environment.Exit 경로)
|
||||
- [ ] **Dialog (변환 대화상자)** — 큐에 파일 추가 → 출력 형식 선택 → 변환 실행 → 결과 산출
|
||||
- [ ] **Register** — `Everything2Everything.exe register` → 셸 컨텍스트 메뉴 등록 성공
|
||||
- [ ] **Unregister** — `Everything2Everything.exe unregister` → 등록 해제 성공
|
||||
- [ ] **Diagnose** — 진단 창이 외부 도구(FFmpeg/LibreOffice) 가용성 상태를 정확히 표시
|
||||
|
||||
## 셸 / 패키징
|
||||
|
||||
- [ ] **셸 컨텍스트 메뉴 initialFiles** — 탐색기에서 파일 선택 후 카스케이드 메뉴 → 추천 출력 목록이 입력 형식에 맞게 노출, 선택 시 변환
|
||||
- [ ] **MSIX 쓰기가능 폴더** — MSIX 패키징 환경에서 임시 작업폴더/설정 저장이 쓰기 가능 경로를 사용(가상화 폴더 권한 문제 없음)
|
||||
|
||||
## 핵심 변환 동작 (P0~P6 내내 GUI 재확인)
|
||||
|
||||
- [ ] 이미지: png↔jpg, png→webp, 이미지 N장 → 단일 PDF/TIFF 결합
|
||||
- [ ] 벡터/데이터: svg→png, csv↔json, csv↔xlsx
|
||||
- [ ] PDF: pdf 압축(pdf→pdf), pdf→이미지
|
||||
- [ ] 외부 도구(있을 때): 영상↔영상/오디오(FFmpeg), HWP/DOCX→PDF(LibreOffice), OCR
|
||||
- [ ] AI(키 설정 시): txt 요약/번역/교정
|
||||
|
||||
## MVVM 분해(P5) 슬라이스별 추가 확인
|
||||
|
||||
- [ ] 미리보기 상태기계(로딩/성공/실패 전환)
|
||||
- [ ] 출력 형식 자동 필터링(큐 교집합) 갱신
|
||||
- [ ] 드래그앤드롭 큐 추가
|
||||
- [ ] 진행률 클램프(0~100%) 및 취소
|
||||
- [ ] 이력(History) 기록/표시
|
||||
318
src/Everything2Everything.Tests/EngineCharacterizationTests.cs
Normal file
318
src/Everything2Everything.Tests/EngineCharacterizationTests.cs
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Everything2Everything.Core;
|
||||
using Everything2Everything.Core.Providers;
|
||||
using ImageMagick;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 엔진 실행경로(ConvertOneAsync / ConvertManyAsync / ExecuteChainAsync / CombineAsync)의 특성화(골든마스터) 테스트.
|
||||
/// 리팩토링(P2~P6) 이전의 "현재 동작"을 실파일 end-to-end로 박제한다 — 이후 모든 PR의 회귀 머지 게이트.
|
||||
///
|
||||
/// 단순 파일 존재 체크 금지(적대적 리뷰 핵심 지적): 출력의 구조 메타(치수/HasAlpha/포맷/프레임수/압축)와
|
||||
/// 내용 지문(솔리드 컬러의 평균색 우세)을 함께 박제해 alpha 처리·압축·인코딩 정책 회귀까지 잡는다.
|
||||
/// 외부 도구(Ghostscript/LibreOffice/FFmpeg) 없이 동작하는 순수 .NET 경로만 사용한다.
|
||||
/// </summary>
|
||||
public class EngineCharacterizationTests
|
||||
{
|
||||
private static string TempDir()
|
||||
{
|
||||
var d = Path.Combine(Path.GetTempPath(), "e2e_char_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
private static string MakeSolidPng(string dir, string name, MagickColor color, uint w, uint h, bool withAlpha = false)
|
||||
{
|
||||
var path = Path.Combine(dir, name);
|
||||
using var img = new MagickImage(color, w, h);
|
||||
if (withAlpha) img.Alpha(AlphaOption.Set);
|
||||
img.Write(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private const string SvgRedSquare =
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='100' height='80'><rect width='100' height='80' fill='red'/></svg>";
|
||||
|
||||
/// <summary>출력 이미지의 평균색(1x1 리샘플) RGB. 솔리드 컬러 입력의 내용 보존을 결정적으로 검증한다.</summary>
|
||||
private static (double R, double G, double B) MeanRgb(IMagickImage<ushort> img)
|
||||
{
|
||||
using var c = (IMagickImage<ushort>)img.Clone();
|
||||
c.Resize(new MagickGeometry(1, 1) { IgnoreAspectRatio = true });
|
||||
using var px = c.GetPixels();
|
||||
var color = px.GetPixel(0, 0).ToColor()!;
|
||||
return (color.R, color.G, color.B);
|
||||
}
|
||||
|
||||
private static (double R, double G, double B) MeanRgb(string path)
|
||||
{
|
||||
using var img = new MagickImage(path);
|
||||
return MeanRgb(img);
|
||||
}
|
||||
|
||||
private static ConvertOptions CustomOut(string dir)
|
||||
=> new() { OutputLocation = OutputLocation.Custom, CustomOutputDirectory = dir };
|
||||
|
||||
// ── 시나리오 1: png→jpg (1홉) — 치수 보존 + alpha 평탄화 + 내용(빨강) 보존 ──────────────
|
||||
[Fact]
|
||||
public async Task Png_To_Jpg_SingleHop_LocksStructureAndContent()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var png = MakeSolidPng(dir, "red.png", MagickColors.Red, 64, 48, withAlpha: true);
|
||||
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
|
||||
var r = await engine.ConvertOneAsync(png, ".jpg", CustomOut(dir));
|
||||
|
||||
Assert.Equal(ConvertStatus.Success, r.Status);
|
||||
Assert.Single(r.OutputPaths);
|
||||
var outPath = r.OutputPaths[0];
|
||||
Assert.Equal(".jpg", Path.GetExtension(outPath).ToLowerInvariant());
|
||||
|
||||
using var img = new MagickImage(outPath);
|
||||
Assert.Equal(MagickFormat.Jpeg, img.Format);
|
||||
Assert.Equal(64u, img.Width);
|
||||
Assert.Equal(48u, img.Height);
|
||||
Assert.False(img.HasAlpha); // jpg는 alpha 비대응 → 평탄화되어야 한다
|
||||
var (rr, gg, bb) = MeanRgb(img);
|
||||
Assert.True(rr > gg && rr > bb, $"빨강 우세 기대, 실제 R={rr} G={gg} B={bb}");
|
||||
}
|
||||
|
||||
// ── 시나리오 2: svg→jpg (멀티홉 래스터, svg→png→jpg) — 치수/포맷 보존 ────────────────────
|
||||
[Fact]
|
||||
public async Task Svg_To_Jpg_MultiHop_LocksStructure()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var svg = Path.Combine(dir, "r.svg");
|
||||
await File.WriteAllTextAsync(svg, SvgRedSquare);
|
||||
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
|
||||
var r = await engine.ConvertOneAsync(svg, ".jpg", CustomOut(dir));
|
||||
|
||||
Assert.Equal(ConvertStatus.Success, r.Status);
|
||||
Assert.Single(r.OutputPaths);
|
||||
using var img = new MagickImage(r.OutputPaths[0]);
|
||||
Assert.Equal(MagickFormat.Jpeg, img.Format);
|
||||
// Svg.Skia는 내부 스케일로 래스터화하므로 정확한 픽셀수(렌더 스케일 상수)는 박제하지 않고,
|
||||
// 의미 있는 불변식인 종횡비 보존(100:80=1.25)과 양수 치수, 내용(빨강)을 박제한다.
|
||||
Assert.True(img.Width > 0 && img.Height > 0);
|
||||
var aspect = (double)img.Width / img.Height;
|
||||
Assert.True(System.Math.Abs(aspect - 1.25) < 0.05, $"종횡비 1.25 기대, 실제 {aspect} ({img.Width}x{img.Height})");
|
||||
var (rr, gg, bb) = MeanRgb(img);
|
||||
Assert.True(rr > gg && rr > bb, $"빨강 우세 기대, 실제 R={rr} G={gg} B={bb}");
|
||||
}
|
||||
|
||||
// ── 시나리오 3: PDF→페이지별 이미지 (직접 변환의 다중 산출물) ───────────────────────────────
|
||||
[Fact]
|
||||
public async Task Pdf_To_Images_ProducesPerPageOutputs()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var pdf = await BuildTwoPagePdf(dir);
|
||||
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
|
||||
var r = await engine.ConvertOneAsync(pdf, ".png", CustomOut(dir));
|
||||
|
||||
Assert.Equal(ConvertStatus.Success, r.Status);
|
||||
Assert.Equal(2, r.OutputPaths.Count); // 2페이지 → 페이지당 1 png
|
||||
foreach (var p in r.OutputPaths)
|
||||
{
|
||||
using var img = new MagickImage(p);
|
||||
Assert.Equal(MagickFormat.Png, img.Format);
|
||||
Assert.True(img.Width > 0 && img.Height > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 시나리오 4: 멀티홉 중간 단계의 다중 산출물 거부 (ConversionEngine.cs:200-205) ───────────
|
||||
[Fact]
|
||||
public async Task MultiHop_RejectsMultiOutputIntermediate()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var src = Path.Combine(dir, "x.a");
|
||||
await File.WriteAllTextAsync(src, "dummy");
|
||||
|
||||
// a→b(2 산출물)→c : 중간 홉 a→b가 여러 파일을 만들면 자동 합성 체인을 이어갈 수 없어야 한다.
|
||||
var reg = new ProviderRegistry(new IConverterProvider[]
|
||||
{
|
||||
new FakeMultiOutputProvider("multi", 2, new ConversionPair(".a", ".b", LossClass.Recode)),
|
||||
new FakeProvider("single", new ConversionPair(".b", ".c", LossClass.Recode)),
|
||||
});
|
||||
var engine = new ConversionEngine(reg);
|
||||
|
||||
var r = await engine.ConvertOneAsync(src, ".c", new ConvertOptions());
|
||||
|
||||
Assert.Equal(ConvertStatus.Failed, r.Status);
|
||||
Assert.Contains("여러 파일", r.Message);
|
||||
}
|
||||
|
||||
// ── 시나리오 5: 이미지 N장 결합 → TIFF (CombineToSingle) — 프레임수/압축/치수/내용 박제 ──────
|
||||
[Fact]
|
||||
public async Task Combine_ImagesToTiff_LocksFramesCompressionAndContent()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var red = MakeSolidPng(dir, "1.png", MagickColors.Red, 40, 30);
|
||||
var green = MakeSolidPng(dir, "2.png", MagickColors.Lime, 40, 30);
|
||||
var blue = MakeSolidPng(dir, "3.png", MagickColors.Blue, 40, 30);
|
||||
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
|
||||
var results = await engine.ConvertManyAsync(
|
||||
new[] { red, green, blue }, ".tif", CustomOut(dir),
|
||||
progress: null, batchMode: BatchMode.CombineToSingle);
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal(ConvertStatus.Success, results[0].Status);
|
||||
Assert.Single(results[0].OutputPaths);
|
||||
|
||||
using var coll = new MagickImageCollection(results[0].OutputPaths[0]);
|
||||
Assert.Equal(3, coll.Count); // 3장 → 3프레임 TIFF
|
||||
// 현재 동작 박제: ConvertOptions.Tiff.Compression 기본 "lzw"를 SetDefine하지만, combine 경로의
|
||||
// 멀티프레임 TIFF 쓰기에서는 실제로 압축이 적용되지 않고 NoCompression으로 저장된다.
|
||||
// ⚠️ 잠재 부채(combine의 Tiff 압축 옵션 미적용) — P3 combine 추출 시 정책 통일을 의식적 결정으로 다룬다.
|
||||
// (지금 LZW로 '고치면' 동작 보존 원칙 위반이자 골든마스터의 의미 상실 → 현재 동작을 그대로 박제)
|
||||
Assert.Equal(CompressionMethod.NoCompression, coll[0].Compression);
|
||||
Assert.Equal(40u, coll[0].Width);
|
||||
Assert.Equal(30u, coll[0].Height);
|
||||
|
||||
var (r0, g0, b0) = MeanRgb(coll[0]);
|
||||
var (r1, g1, b1) = MeanRgb(coll[1]);
|
||||
var (r2, g2, b2) = MeanRgb(coll[2]);
|
||||
Assert.True(r0 > g0 && r0 > b0, "프레임0 빨강 우세 기대");
|
||||
Assert.True(g1 > r1 && g1 > b1, "프레임1 초록 우세 기대");
|
||||
Assert.True(b2 > r2 && b2 > g2, "프레임2 파랑 우세 기대");
|
||||
}
|
||||
|
||||
// ── 시나리오 6: 이미지 N장 결합 → PDF (CombineToSingle) — 단일 PDF 산출 ──────────────────────
|
||||
[Fact]
|
||||
public async Task Combine_ImagesToPdf_ProducesSinglePdf()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var pdf = await BuildTwoPagePdf(dir); // 내부적으로 combine→pdf 사용
|
||||
Assert.True(File.Exists(pdf));
|
||||
Assert.Equal(".pdf", Path.GetExtension(pdf).ToLowerInvariant());
|
||||
|
||||
var header = new byte[5];
|
||||
using (var fs = File.OpenRead(pdf)) _ = fs.Read(header, 0, 5);
|
||||
Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(header)); // 유효 PDF 헤더
|
||||
Assert.True(new FileInfo(pdf).Length > 200);
|
||||
}
|
||||
|
||||
// ── 시나리오 7: 멀티홉 마지막 홉 Skip 존중 (ConversionEngine.cs:182-192) ──────────────────────
|
||||
[Fact]
|
||||
public async Task MultiHop_LastHopSkip_IsRespected()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var svg = Path.Combine(dir, "r.svg");
|
||||
await File.WriteAllTextAsync(svg, SvgRedSquare);
|
||||
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
|
||||
// 1차: svg→png→jpg 로 jpg 생성
|
||||
var first = await engine.ConvertOneAsync(svg, ".jpg", CustomOut(dir));
|
||||
Assert.Equal(ConvertStatus.Success, first.Status);
|
||||
|
||||
// 2차: 동일 출력이 존재 + OnCollision=Skip → 마지막 홉(png→jpg)이 Skip을 반환, 엔진은 Skip으로 존중
|
||||
var skipOpts = CustomOut(dir);
|
||||
skipOpts.OnCollision = NameCollision.Skip;
|
||||
var second = await engine.ConvertOneAsync(svg, ".jpg", skipOpts);
|
||||
Assert.Equal(ConvertStatus.Skipped, second.Status);
|
||||
}
|
||||
|
||||
// ── 시나리오 8: 단일 홉 Skip 통과 (ConversionEngine.cs:133-145) ────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SingleHop_Skip_PassesThrough()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var png = MakeSolidPng(dir, "red.png", MagickColors.Red, 32, 32);
|
||||
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
|
||||
var first = await engine.ConvertOneAsync(png, ".jpg", CustomOut(dir));
|
||||
Assert.Equal(ConvertStatus.Success, first.Status);
|
||||
|
||||
var skipOpts = CustomOut(dir);
|
||||
skipOpts.OnCollision = NameCollision.Skip;
|
||||
var second = await engine.ConvertOneAsync(png, ".jpg", skipOpts);
|
||||
Assert.Equal(ConvertStatus.Skipped, second.Status);
|
||||
}
|
||||
|
||||
// ── 시나리오 9: 동일포맷 + self-edge 없음 → Skip (ConversionEngine.cs:94-95) ───────────────────
|
||||
[Fact]
|
||||
public async Task SameFormat_NoSelfEdge_Skips()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var csv = Path.Combine(dir, "t.csv");
|
||||
await File.WriteAllTextAsync(csv, "a,b\n1,2\n");
|
||||
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
|
||||
// csv는 csv→csv self-edge가 없으므로 동일포맷 변환은 Skip 되어야 한다.
|
||||
var r = await engine.ConvertOneAsync(csv, ".csv", CustomOut(dir));
|
||||
|
||||
Assert.Equal(ConvertStatus.Skipped, r.Status);
|
||||
Assert.Contains("동일", r.Message);
|
||||
}
|
||||
|
||||
// ── 시나리오 10: png→png 재압축 (imageoptim self-edge) — alpha 보존 ────────────────────────────
|
||||
[Fact]
|
||||
public async Task Png_To_Png_Optimize_PreservesAlpha()
|
||||
{
|
||||
var dir = TempDir();
|
||||
// 진짜 반투명(alpha=0.5) 빨강 — 불투명 alpha는 PNG 저장 시 제거되므로 실제 투명도가 있어야 보존을 검증할 수 있다.
|
||||
var png = Path.Combine(dir, "a.png");
|
||||
using (var src = new MagickImage(new MagickColor(65535, 0, 0, 32768), 50, 50))
|
||||
src.Write(png);
|
||||
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
|
||||
var r = await engine.ConvertOneAsync(png, ".png", CustomOut(dir));
|
||||
|
||||
Assert.Equal(ConvertStatus.Success, r.Status);
|
||||
Assert.Single(r.OutputPaths);
|
||||
using var img = new MagickImage(r.OutputPaths[0]);
|
||||
Assert.Equal(MagickFormat.Png, img.Format);
|
||||
Assert.True(img.HasAlpha); // png self-edge 최적화는 alpha 채널을 보존해야 한다
|
||||
}
|
||||
|
||||
/// <summary>combine→pdf 경로로 2페이지 PDF를 만든다(순수 .NET, Ghostscript 불필요).</summary>
|
||||
private static async Task<string> BuildTwoPagePdf(string dir)
|
||||
{
|
||||
var p1 = MakeSolidPng(dir, "pg1.png", MagickColors.White, 120, 160);
|
||||
var p2 = MakeSolidPng(dir, "pg2.png", MagickColors.White, 120, 160);
|
||||
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
var results = await engine.ConvertManyAsync(
|
||||
new[] { p1, p2 }, ".pdf", CustomOut(dir),
|
||||
progress: null, batchMode: BatchMode.CombineToSingle);
|
||||
Assert.Single(results);
|
||||
Assert.Equal(ConvertStatus.Success, results[0].Status);
|
||||
return results[0].OutputPaths[0];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>중간 홉의 다중 산출물 거부(line 200-205) 특성화용 — 지정 개수의 더미 출력을 반환한다.</summary>
|
||||
internal sealed class FakeMultiOutputProvider : IConverterProvider
|
||||
{
|
||||
private readonly int _count;
|
||||
|
||||
public ProviderCapability Capability { get; }
|
||||
|
||||
public FakeMultiOutputProvider(string id, int outputCount, params ConversionPair[] pairs)
|
||||
{
|
||||
_count = outputCount;
|
||||
Capability = new ProviderCapability(
|
||||
Id: id, DisplayName: id, SupportedConversions: pairs,
|
||||
Status: ProviderStatus.Available, Summary: id,
|
||||
ExternalDependencies: Array.Empty<ExternalDependency>());
|
||||
}
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ProviderAvailability.Ready);
|
||||
|
||||
public Task<ConvertResult> ConvertAsync(string sourcePath, string outputDirectory, string outputExtension,
|
||||
ConvertOptions options, IProgress<double>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
var outs = Enumerable.Range(0, _count)
|
||||
.Select(i => Path.Combine(outputDirectory, $"{baseName}_{i}{outputExtension}"))
|
||||
.ToArray();
|
||||
return Task.FromResult(ConvertResult.Ok(sourcePath, outs));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
using System.Linq;
|
||||
using Everything2Everything.Core;
|
||||
using Everything2Everything.Core.Providers;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 기본 엔진의 실제 변환 그래프에 대한 라우팅 매트릭스 특성화. FindBestPath/ReachableOutputs는 순수 함수라
|
||||
/// 비결정성이 없다. UI 추천 출력(커밋 573d045)과 멀티홉 자동 합성의 현재 동작을 박제해
|
||||
/// 리팩토링(P2~P6)이 라우팅을 바꾸면 즉시 드러나게 한다.
|
||||
/// </summary>
|
||||
public class RoutingMatrixCharacterizationTests
|
||||
{
|
||||
private static ConversionGraph Graph() => Everything2EverythingBootstrap.CreateDefault().Providers.Graph;
|
||||
private static ProviderRegistry Registry() => Everything2EverythingBootstrap.CreateDefault().Providers;
|
||||
|
||||
[Theory]
|
||||
// 이미지 직접 변환 (magick, 1홉)
|
||||
[InlineData(".png", ".jpg", 1)]
|
||||
[InlineData(".jpg", ".png", 1)]
|
||||
[InlineData(".png", ".webp", 1)]
|
||||
[InlineData(".png", ".pdf", 1)]
|
||||
[InlineData(".pdf", ".png", 1)] // 래스터 (pdf provider)
|
||||
// 벡터 (vector provider)
|
||||
[InlineData(".svg", ".png", 1)]
|
||||
[InlineData(".svg", ".pdf", 1)]
|
||||
[InlineData(".svg", ".jpg", 2)] // 멀티홉 svg→png→jpg
|
||||
// 표 데이터 (data provider, 도그푸딩)
|
||||
[InlineData(".csv", ".json", 1)]
|
||||
[InlineData(".json", ".csv", 1)]
|
||||
[InlineData(".csv", ".xlsx", 1)]
|
||||
[InlineData(".xlsx", ".csv", 1)]
|
||||
[InlineData(".json", ".xlsx", 2)] // 멀티홉 json→csv→xlsx
|
||||
public void FindBestPath_HasExpectedHopCount(string input, string output, int expectedHops)
|
||||
{
|
||||
var path = Graph().FindBestPath(input, output, maxHops: 3);
|
||||
Assert.NotNull(path);
|
||||
Assert.Equal(expectedHops, path!.Count);
|
||||
Assert.Equal(ConversionPair.Normalize(input), path[0].From);
|
||||
Assert.Equal(ConversionPair.Normalize(output), path[^1].To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfEdges_AndMissingSelfEdges_AreLocked()
|
||||
{
|
||||
var g = Graph();
|
||||
Assert.NotNull(g.FindBestPath(".png", ".png")); // imageoptim 재압축 self-edge
|
||||
Assert.NotNull(g.FindBestPath(".jpg", ".jpg")); // imageoptim 재압축 self-edge
|
||||
Assert.NotNull(g.FindBestPath(".pdf", ".pdf")); // pdftool 압축 self-edge
|
||||
Assert.Null(g.FindBestPath(".csv", ".csv")); // data provider는 csv self-edge 없음 → 엔진 Skip
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AvoidLossy_DropsRasterizeOnlyRoutes()
|
||||
{
|
||||
var g = Graph();
|
||||
// pdf→png 유일 경로는 래스터화. allowLossy=false면 경로가 없어야 한다.
|
||||
Assert.NotNull(g.FindBestPath(".pdf", ".png", maxHops: 3, allowLossy: true));
|
||||
Assert.Null(g.FindBestPath(".pdf", ".png", maxHops: 3, allowLossy: false));
|
||||
|
||||
// png→jpg는 Recode(래스터 아님) → allowLossy=false여도 유지된다.
|
||||
Assert.NotNull(g.FindBestPath(".png", ".jpg", maxHops: 1, allowLossy: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputsForInput_ExposesUiRecommendedSets()
|
||||
{
|
||||
var reg = Registry();
|
||||
|
||||
var png = reg.OutputsForInput(".png");
|
||||
Assert.Contains(".jpg", png);
|
||||
Assert.Contains(".webp", png);
|
||||
Assert.Contains(".pdf", png);
|
||||
Assert.DoesNotContain(".png", png); // 동일포맷 self는 '다른 형식 변환' 목록에서 제외
|
||||
|
||||
var svg = reg.OutputsForInput(".svg");
|
||||
Assert.Contains(".png", svg);
|
||||
Assert.Contains(".jpg", svg); // 멀티홉 노출
|
||||
Assert.Contains(".pdf", svg);
|
||||
|
||||
var json = reg.OutputsForInput(".json");
|
||||
Assert.Contains(".csv", json);
|
||||
Assert.Contains(".xlsx", json); // 멀티홉 json→csv→xlsx
|
||||
Assert.DoesNotContain(".json", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReachableOutputs_InvariantHoldsOnRealGraph()
|
||||
{
|
||||
var g = Graph();
|
||||
// 불변식: ReachableOutputs가 반환한 모든 출력은 FindBestPath로 실제 경로가 나와야 한다.
|
||||
// (UI 노출은 2홉 기준 — ProviderRegistry.OutputsForInput과 동일)
|
||||
foreach (var input in new[] { ".png", ".jpg", ".svg", ".csv", ".json", ".pdf" })
|
||||
foreach (var outExt in g.ReachableOutputs(input, maxHops: 2, allowLossy: true))
|
||||
Assert.NotNull(g.FindBestPath(input, outExt, maxHops: 2));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue