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