diff --git a/docs/ssot/REFACTOR_SMOKE_CHECKLIST.md b/docs/ssot/REFACTOR_SMOKE_CHECKLIST.md new file mode 100644 index 0000000..8f85d0e --- /dev/null +++ b/docs/ssot/REFACTOR_SMOKE_CHECKLIST.md @@ -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) 기록/표시 diff --git a/src/Everything2Everything.Tests/EngineCharacterizationTests.cs b/src/Everything2Everything.Tests/EngineCharacterizationTests.cs new file mode 100644 index 0000000..43891b5 --- /dev/null +++ b/src/Everything2Everything.Tests/EngineCharacterizationTests.cs @@ -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; + +/// +/// 엔진 실행경로(ConvertOneAsync / ConvertManyAsync / ExecuteChainAsync / CombineAsync)의 특성화(골든마스터) 테스트. +/// 리팩토링(P2~P6) 이전의 "현재 동작"을 실파일 end-to-end로 박제한다 — 이후 모든 PR의 회귀 머지 게이트. +/// +/// 단순 파일 존재 체크 금지(적대적 리뷰 핵심 지적): 출력의 구조 메타(치수/HasAlpha/포맷/프레임수/압축)와 +/// 내용 지문(솔리드 컬러의 평균색 우세)을 함께 박제해 alpha 처리·압축·인코딩 정책 회귀까지 잡는다. +/// 외부 도구(Ghostscript/LibreOffice/FFmpeg) 없이 동작하는 순수 .NET 경로만 사용한다. +/// +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 = + ""; + + /// 출력 이미지의 평균색(1x1 리샘플) RGB. 솔리드 컬러 입력의 내용 보존을 결정적으로 검증한다. + private static (double R, double G, double B) MeanRgb(IMagickImage img) + { + using var c = (IMagickImage)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 채널을 보존해야 한다 + } + + /// combine→pdf 경로로 2페이지 PDF를 만든다(순수 .NET, Ghostscript 불필요). + private static async Task 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]; + } +} + +/// 중간 홉의 다중 산출물 거부(line 200-205) 특성화용 — 지정 개수의 더미 출력을 반환한다. +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()); + } + + public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + => Task.FromResult(ProviderAvailability.Ready); + + public Task ConvertAsync(string sourcePath, string outputDirectory, string outputExtension, + ConvertOptions options, IProgress? 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)); + } +} diff --git a/src/Everything2Everything.Tests/RoutingMatrixCharacterizationTests.cs b/src/Everything2Everything.Tests/RoutingMatrixCharacterizationTests.cs new file mode 100644 index 0000000..59a15ba --- /dev/null +++ b/src/Everything2Everything.Tests/RoutingMatrixCharacterizationTests.cs @@ -0,0 +1,98 @@ +using System.Linq; +using Everything2Everything.Core; +using Everything2Everything.Core.Providers; +using Xunit; + +namespace Everything2Everything.Tests; + +/// +/// 기본 엔진의 실제 변환 그래프에 대한 라우팅 매트릭스 특성화. FindBestPath/ReachableOutputs는 순수 함수라 +/// 비결정성이 없다. UI 추천 출력(커밋 573d045)과 멀티홉 자동 합성의 현재 동작을 박제해 +/// 리팩토링(P2~P6)이 라우팅을 바꾸면 즉시 드러나게 한다. +/// +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)); + } +}