1
0
Fork 0

feat(P1): 변환 그래프 엔진 + 멀티홉 경로탐색 + PDF 압축

ProviderRegistry를 ConversionGraph로 승격하고 ConversionEngine이 자체 Dijkstra로 멀티홉 경로를 자동 합성하도록 전환. 손실을 LossClass 가중치로 SSOT화.

- ConversionGraph: (node,hop) 라벨링 Dijkstra로 홉 제약 최단경로 (일반 Dijkstra의 hops 오염 false negative 회피)
- ConversionEngine.ExecuteChainAsync: 멀티홉 체이닝, 중간 다중출력 거부, 마지막 홉 Status 존중
- PdfToolProvider: pdf->pdf 구조 압축 (PDFsharp, Save 원자화)
- PairsFromMatrix 자기쌍 제외 (png->png 재인코딩 회귀 방지), PDF->이미지 Rasterize 표시
- xUnit 테스트 프로젝트 신설 + 그래프/회귀 14개 통과
- 적대적 병렬 리뷰(27 agents)로 확정된 13건 중 정확성 핵심 수정

SSOT: P1 done
This commit is contained in:
Yun Chan 2026-06-01 12:48:58 +09:00
parent 232f453e15
commit e3ef59e009
16 changed files with 641 additions and 30 deletions

View file

@ -1,4 +1,5 @@
<Solution>
<Project Path="src/Everything2Everything.Core/Everything2Everything.Core.csproj" />
<Project Path="src/Everything2Everything.App/Everything2Everything.App.csproj" />
<Project Path="src/Everything2Everything.Tests/Everything2Everything.Tests.csproj" />
</Solution>

View file

@ -100,7 +100,7 @@ Everything2Everything의 북극성은 "세상의 모든 변환을 원자(atomic)
## 실행 로드맵
### P1 · 그래프 엔진 도입 + 즉시 체감 가치(PDF 압축) `effort:L` `risk:medium` `status:planned`
### P1 · 그래프 엔진 도입 + 즉시 체감 가치(PDF 압축) `effort:L` `risk:medium` `status:done`
**목표:** ProviderRegistry를 ConversionGraph로 승격하고 멀티홉 경로 탐색을 엔진에 내장한다. 동시에 PDF 압축이라는 즉시 체감 신기능을 출시해 '보이지 않는 리팩터링의 함정'을 회피한다.
**산출물:**

View file

@ -1,6 +1,6 @@
{
"_comment": "로드맵 단계별 진행 상태. 값: planned | in_progress | done. 갱신 후 `python build.py`로 index.html/PLAN.md 재생성.",
"P1": "planned",
"P1": "done",
"P2": "planned",
"P3": "planned",
"P4": "planned",

View file

@ -395,7 +395,7 @@ footer{border-top:1px solid var(--line2);margin-top:60px;padding:30px 0;color:va
<div class="tl"><details class="ph" open><summary>
<div class="pno">P1</div>
<div class="pti"><b>그래프 엔진 도입 + 즉시 체감 가치(PDF 압축)</b><div class="pg">ProviderRegistry를 ConversionGraph로 승격하고 멀티홉 경로 탐색을 엔진에 내장한다. 동시에 PDF 압축이라는 즉시 체감 신기능을 출시해 &#x27;보이지 않는 리팩터링의 함정&#x27;을 회피한다.</div></div>
<div class="pbadges"><span class="b b-plan">예정</span><span class="b b-l">L</span><span class="b b-medium">RISK medium</span></div>
<div class="pbadges"><span class="b b-done">완료</span><span class="b b-l">L</span><span class="b b-medium">RISK medium</span></div>
</summary>
<div class="body">
<h4>산출물</h4><ul class="lst"><li>ConversionGraph + 자체 Dijkstra PathFinder(외부 의존성 0, .NET 9 PriorityQueue)</li><li>ConversionPair.LossClass 필드 + 정적 가중치 테이블</li><li>ConversionEngine.ConvertOneAsync 그래프 위임 + ChainExecutor(공용 workDir 헬퍼)</li><li>PdfToolProvider 신설: PDF 압축(Light=PDFsharp 구조최적화, Strong=PDFium 렌더+Magick 재인코딩, Max=Ghostscript 외부폴백) + 병합/분할</li><li>xUnit 테스트 프로젝트 신설(현재 0개) + 그래프 경로탐색 회귀 테스트</li></ul>

View file

@ -85,11 +85,15 @@ public sealed class ConversionEngine
var output = ConversionPair.Normalize(outputExtension);
var inputExt = ConversionPair.Normalize(Path.GetExtension(sourcePath));
if (string.Equals(inputExt, output, StringComparison.OrdinalIgnoreCase))
return ConvertResult.Skip(sourcePath, "입력과 출력 형식이 동일해 변환이 필요하지 않습니다.");
// 그래프 경로 탐색: 직접 엣지가 있으면 1홉, 없으면 손실 가중치 기반 멀티홉을 자동 합성.
var maxHops = options.AllowMultiHop ? Math.Max(1, options.MaxHops) : 1;
var path = _registry.Graph.FindBestPath(inputExt, output, maxHops, !options.AvoidLossy);
if (!_registry.TryGet(sourcePath, output, out var provider) || provider is null)
if (path is null || path.Count == 0)
{
if (string.Equals(inputExt, output, StringComparison.OrdinalIgnoreCase))
return ConvertResult.Skip(sourcePath, "입력과 출력 형식이 동일해 변환이 필요하지 않습니다.");
var available = _registry.OutputsForFile(sourcePath);
var hint = available.Count > 0
? $" 가능한 출력: {string.Join(", ", available)}"
@ -97,30 +101,117 @@ public sealed class ConversionEngine
return ConvertResult.Fail(sourcePath, $"{inputExt} → {output} 변환을 지원하지 않습니다.{hint}");
}
var availability = await provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
if (!availability.IsReady)
return await ExecuteChainAsync(sourcePath, output, path, options, progress, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// 변환 경로(1홉 또는 멀티홉)를 순차 실행한다. 중간 산출물은 임시 작업폴더에 체이닝하고
/// 마지막 홉만 실제 출력 폴더에 쓴다. 각 홉 시작 전 Provider 가용성을 점검한다.
/// </summary>
private async Task<ConvertResult> ExecuteChainAsync(
string sourcePath,
string finalOutputExtension,
IReadOnlyList<ConversionGraph.Edge> path,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
foreach (var edge in path)
{
var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty<string>();
var detail = availability.Reason ?? "필수 의존성이 준비되지 않았습니다.";
if (missing.Any()) detail += $" (필요: {string.Join(", ", missing)})";
return ConvertResult.Fail(sourcePath, detail);
var availability = await edge.Provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
if (!availability.IsReady)
{
var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty<string>();
var detail = availability.Reason ?? "필수 의존성이 준비되지 않았습니다.";
if (missing.Any()) detail += $" (필요: {string.Join(", ", missing)})";
return ConvertResult.Fail(sourcePath, detail);
}
}
var outputDir = ResolveOutputDirectory(sourcePath, output, options);
Directory.CreateDirectory(outputDir);
// 단일 홉 — 기존 동작과 동일 (출력 폴더 해결 후 직접 위임)
if (path.Count == 1)
{
var outputDir = ResolveOutputDirectory(sourcePath, finalOutputExtension, options);
Directory.CreateDirectory(outputDir);
try
{
return await path[0].Provider
.ConvertAsync(sourcePath, outputDir, finalOutputExtension, options, progress, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { return ConvertResult.Fail(sourcePath, ex.Message, ex); }
}
// 멀티홉 — 중간 산출물은 임시 폴더, 마지막만 실제 출력
var workRoot = Path.Combine(Path.GetTempPath(), "e2e_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workRoot);
try
{
return await provider.ConvertAsync(sourcePath, outputDir, output, options, progress, cancellationToken)
.ConfigureAwait(false);
var current = sourcePath;
for (var h = 0; h < path.Count; h++)
{
cancellationToken.ThrowIfCancellationRequested();
var edge = path[h];
var isLast = h == path.Count - 1;
var hopExt = edge.To;
var outDir = isLast
? ResolveOutputDirectory(sourcePath, finalOutputExtension, options)
: Path.Combine(workRoot, "h" + h.ToString());
Directory.CreateDirectory(outDir);
var hopIndex = h;
var hopProgress = new Progress<double>(p =>
progress?.Report((hopIndex + Math.Clamp(p, 0, 1)) / path.Count));
ConvertResult result;
try
{
result = await edge.Provider
.ConvertAsync(current, outDir, hopExt, options, hopProgress, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
return ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {ex.Message}", ex);
}
if (isLast)
{
// 마지막 홉은 상태를 그대로 존중 (정상 Skip을 Fail로 둔갑시키지 않음)
return result.Status switch
{
ConvertStatus.Success => ConvertResult.Ok(sourcePath, result.OutputPaths),
ConvertStatus.Skipped => ConvertResult.Skip(sourcePath, result.Message ?? "건너뛰었습니다."),
_ => ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {result.Message ?? " ."}", result.Error),
};
}
// 중간 홉은 단일 산출물이어야 다음 홉으로 체이닝할 수 있다.
if (result.Status != ConvertStatus.Success || result.OutputPaths.Count == 0)
{
return ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {result.Message ?? " ."}");
}
if (result.OutputPaths.Count > 1)
{
// 다중 산출물(예: PDF→페이지별 이미지)을 중간 홉으로 두면 나머지가 유실된다 — 명시적 거부.
return ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To})가 여러 파일을 생성해 자동 합성된 다음 단계로 이어갈 수 없습니다. 직접 변환 경로를 사용하거나 단계를 나눠 실행하세요.");
}
current = result.OutputPaths[0]; // 다음 홉의 입력
}
return ConvertResult.Fail(sourcePath, "변환 체인 실행에 실패했습니다.");
}
catch (OperationCanceledException)
finally
{
throw;
}
catch (Exception ex)
{
return ConvertResult.Fail(sourcePath, ex.Message, ex);
try { Directory.Delete(workRoot, true); } catch { /* 임시 정리 실패 무시 */ }
}
}

View file

@ -54,6 +54,18 @@ public sealed class ConvertOptions
public OcrOptions Ocr { get; set; } = new();
// --- 변환 그래프 경로 옵션 (P1) ---
/// <summary>멀티홉 경로 자동 합성 허용. false면 직접(1홉) 변환만.</summary>
public bool AllowMultiHop { get; set; } = true;
/// <summary>멀티홉 최대 홉 수.</summary>
public int MaxHops { get; set; } = 3;
/// <summary>래스터화 같은 큰 손실 엣지를 회피한다.</summary>
public bool AvoidLossy { get; set; } = false;
public PdfCompressOptions PdfCompress { get; set; } = new();
public static ConvertOptions Quick() => new();
}
@ -122,3 +134,9 @@ public sealed class OcrOptions
public bool PreserveLayout { get; set; } = true;
public string Backend { get; set; } = "auto";
}
public sealed class PdfCompressOptions
{
/// <summary>Light(구조 최적화·무손실) | Strong(렌더 재인코딩) | Max(Ghostscript). P1은 Light만 구현.</summary>
public string Level { get; set; } = "Light";
}

View file

@ -14,7 +14,7 @@ public sealed class PdfProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "pdf",
DisplayName: "PDF",
SupportedConversions: ProviderCapability.PairsFromMatrix(PdfInputs, PdfRenderOutputs),
SupportedConversions: ProviderCapability.PairsFromMatrix(PdfInputs, PdfRenderOutputs, LossClass.Rasterize),
Status: ProviderStatus.Available,
Summary: "PDF 각 페이지를 PNG/JPEG/WebP/AVIF/BMP/TIFF로 렌더링합니다.",
ExternalDependencies: Array.Empty<ExternalDependency>(),

View file

@ -0,0 +1,88 @@
using Everything2Everything.Core.Providers;
using PdfSharp.Pdf;
using PdfSharp.Pdf.IO;
namespace Everything2Everything.Core.Converters;
/// <summary>
/// PDF 재압축/최적화 Provider. pdf→pdf 동일포맷 변환을 그래프 엣지로 노출해
/// ConversionEngine의 동일포맷 Skip을 우회한다. P1은 PDFsharp(MIT) in-process 구조 최적화(Light).
/// Strong(렌더 재인코딩)·Max(Ghostscript 외부)는 후속 단계에서 확장.
/// </summary>
public sealed class PdfToolProvider : IConverterProvider
{
public ProviderCapability Capability { get; } = new(
Id: "pdf-tool",
DisplayName: "PDF 압축",
SupportedConversions: new[] { new ConversionPair(".pdf", ".pdf", LossClass.Container) },
Status: ProviderStatus.Available,
Summary: "PDF를 재압축해 용량을 줄입니다 (구조 최적화). 전자서명·XFA 폼·PDF/A 준수는 재저장 과정에서 유실될 수 있습니다.",
ExternalDependencies: Array.Empty<ExternalDependency>(),
RoadmapNote: "Strong(렌더 재인코딩)·Max(Ghostscript) 압축 레벨은 후속 추가 예정");
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)
=> Task.Run(() => Compress(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
private static ConvertResult Compress(
string sourcePath,
string outputDirectory,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
var outPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, "_compressed", ".pdf", options.OnCollision);
if (OutputPathHelper.ShouldSkip(outPath, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
progress?.Report(0.1);
// 임시 파일에 먼저 저장한 뒤 원자적으로 교체 — Save 도중 예외가 나도 손상된 출력이 잔존하지 않게.
var tmp = outPath + ".tmp";
try
{
using (var doc = PdfReader.Open(sourcePath, PdfDocumentOpenMode.Modify))
{
doc.Options.CompressContentStreams = true;
doc.Options.NoCompression = false;
doc.Options.EnableCcittCompressionForBilevelImages = true;
cancellationToken.ThrowIfCancellationRequested();
doc.Save(tmp);
}
progress?.Report(0.9);
// 재압축이 오히려 더 커지면 원본을 사용(출력이 입력보다 크지 않도록 보장).
var before = new FileInfo(sourcePath).Length;
var after = new FileInfo(tmp).Length;
if (after > before)
{
File.Copy(sourcePath, outPath, overwrite: true);
File.Delete(tmp);
}
else
{
if (File.Exists(outPath)) File.Delete(outPath);
File.Move(tmp, outPath);
}
}
catch
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* 임시 정리 실패 무시 */ }
throw;
}
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { outPath });
}
}

View file

@ -20,6 +20,7 @@
<PackageReference Include="DocumentFormat.OpenXml" Version="3.1.0" />
<PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="ReverseMarkdown" Version="4.6.0" />
<PackageReference Include="PDFsharp" Version="6.2.0" />
</ItemGroup>
<ItemGroup>

View file

@ -13,6 +13,7 @@ public static class Everything2EverythingBootstrap
magick,
new Converters.HeicProvider(magick),
pdf,
new Converters.PdfToolProvider(),
new Converters.DocxProvider(pdf),
new Converters.HtmlProvider(),
new Converters.HwpxProvider(),

View file

@ -0,0 +1,134 @@
namespace Everything2Everything.Core.Providers;
/// <summary>
/// 모든 Provider의 변환 능력을 방향 그래프로 합성한다. 노드=확장자, 엣지=Provider+LossClass 가중치.
/// 자체 Dijkstra(외부 의존성 0, .NET PriorityQueue)로 임의의 입력→출력 멀티홉 경로를 최저 손실로 탐색한다.
/// 홉 제약(maxHops)이 있는 최단경로이므로 상태를 (노드, 홉수)로 두는 라벨링 Dijkstra를 쓴다.
/// (일반 Dijkstra는 hops 값이 '최저비용 경로의 홉'으로 오염돼 유효한 경로를 놓치는 false negative가 생긴다.)
/// </summary>
public sealed class ConversionGraph
{
public readonly record struct Edge(string From, string To, IConverterProvider Provider, LossClass Loss)
{
public double Weight => LossWeights.Of(Loss) + LossWeights.HopPenalty;
}
private readonly Dictionary<string, List<Edge>> _adj = new(StringComparer.OrdinalIgnoreCase);
public void AddEdge(string from, string to, IConverterProvider provider, LossClass loss)
{
var f = ConversionPair.Normalize(from);
var t = ConversionPair.Normalize(to);
if (!_adj.TryGetValue(f, out var list))
_adj[f] = list = new List<Edge>();
list.Add(new Edge(f, t, provider, loss));
}
public bool HasNode(string ext) => _adj.ContainsKey(ConversionPair.Normalize(ext));
public IReadOnlyList<Edge> EdgesFrom(string ext)
=> _adj.TryGetValue(ConversionPair.Normalize(ext), out var list) ? list : Array.Empty<Edge>();
/// <summary>
/// 입력에서 도달 가능한 모든 출력 확장자 (홉 제약 도달성). FindBestPath와 동일한 (노드,홉) 도달 기준을 공유하므로
/// "ReachableOutputs가 반환한 모든 X에 대해 FindBestPath(input, X) != null" 불변식이 성립한다.
/// </summary>
public IReadOnlyCollection<string> ReachableOutputs(string inputExt, int maxHops = 3, bool allowLossy = true)
{
var start = ConversionPair.Normalize(inputExt);
if (maxHops < 1) maxHops = 1;
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var visited = new HashSet<(string, int)>();
var q = new Queue<(string Node, int Hop)>();
q.Enqueue((start, 0));
visited.Add((start, 0));
// 동일포맷 self-edge(예: pdf→pdf 압축)도 도달 출력으로 노출
foreach (var e in EdgesFrom(start))
if (string.Equals(e.To, start, StringComparison.OrdinalIgnoreCase) && (allowLossy || e.Loss != LossClass.Rasterize))
result.Add(e.To);
while (q.Count > 0)
{
var (node, hop) = q.Dequeue();
if (hop >= maxHops) continue;
foreach (var e in EdgesFrom(node))
{
if (!allowLossy && e.Loss == LossClass.Rasterize) continue;
if (!string.Equals(e.To, start, StringComparison.OrdinalIgnoreCase))
result.Add(e.To);
var next = (e.To, hop + 1);
if (visited.Add(next))
q.Enqueue(next);
}
}
return result;
}
/// <summary>
/// 입력→출력 최저 손실 경로 (홉 ≤ maxHops). 직접(1홉) 엣지가 있으면 홉 페널티로 거의 항상 최소가 된다.
/// 동일포맷(start==goal)은 self-edge(압축 등)가 있을 때만 경로를 반환하고, 없으면 null(엔진이 Skip 처리).
/// </summary>
public IReadOnlyList<Edge>? FindBestPath(string inputExt, string outputExt, int maxHops = 3, bool allowLossy = true)
{
var start = ConversionPair.Normalize(inputExt);
var goal = ConversionPair.Normalize(outputExt);
if (maxHops < 1) maxHops = 1;
if (string.Equals(start, goal, StringComparison.OrdinalIgnoreCase))
{
Edge? best = null;
foreach (var e in EdgesFrom(start))
{
if (!string.Equals(e.To, goal, StringComparison.OrdinalIgnoreCase)) continue;
if (!allowLossy && e.Loss == LossClass.Rasterize) continue;
if (best is null || e.Weight < best.Value.Weight) best = e;
}
return best is null ? null : new[] { best.Value };
}
if (!_adj.ContainsKey(start)) return null;
// 상태 = (노드, 홉수). 홉수를 상태에 포함해야 홉 제약 최단경로를 정확히 푼다.
var dist = new Dictionary<(string Node, int Hop), double>();
var prev = new Dictionary<(string Node, int Hop), (Edge Edge, string FromNode, int FromHop)>();
var visited = new HashSet<(string, int)>();
var pq = new PriorityQueue<(string Node, int Hop), double>();
dist[(start, 0)] = 0;
pq.Enqueue((start, 0), 0);
(string Node, int Hop)? goalState = null;
while (pq.TryDequeue(out var s, out var cost))
{
if (!visited.Add(s)) continue;
if (string.Equals(s.Node, goal, StringComparison.OrdinalIgnoreCase)) { goalState = s; break; }
if (s.Hop >= maxHops) continue;
foreach (var e in EdgesFrom(s.Node))
{
if (!allowLossy && e.Loss == LossClass.Rasterize) continue;
var next = (e.To, s.Hop + 1);
var nd = cost + e.Weight;
if (!dist.TryGetValue(next, out var c) || nd < c)
{
dist[next] = nd;
prev[next] = (e, s.Node, s.Hop);
pq.Enqueue(next, nd);
}
}
}
if (goalState is null) return null;
var path = new List<Edge>();
var cur = goalState.Value;
while (!(string.Equals(cur.Node, start, StringComparison.OrdinalIgnoreCase) && cur.Hop == 0))
{
var p = prev[cur];
path.Add(p.Edge);
cur = (p.FromNode, p.FromHop);
}
path.Reverse();
return path;
}
}

View file

@ -1,9 +1,42 @@
namespace Everything2Everything.Core.Providers;
public sealed record ConversionPair(string InputExtension, string OutputExtension)
/// <summary>
/// 변환 1홉의 품질 손실 등급. 멀티홉 경로 선택(Dijkstra 가중치)과 UI '손실 변환' 배지의 단일 출처(SSOT).
/// 손실은 곱셈적이므로 가중치를 -log(보존율) 근사로 두면 덧셈 최단경로가 곧 최대 품질보존 경로가 된다.
/// </summary>
public enum LossClass
{
public static ConversionPair Of(string input, string output)
=> new(Normalize(input), Normalize(output));
/// <summary>무손실 — 픽셀/내용 완전 보존 (예: png→png 메타 정리, 컨테이너 무손실 재포장).</summary>
Lossless,
/// <summary>컨테이너/구조 변경, 내용 보존 (예: pdf 구조 재압축, tiff 압축 방식 변경).</summary>
Container,
/// <summary>재인코딩/손실 압축 (예: jpg 품질 인코딩, docx→pdf 렌더).</summary>
Recode,
/// <summary>래스터화 — 벡터/텍스트의 편집성 상실 (예: pdf/svg→png). 단방향 손실 절벽.</summary>
Rasterize,
}
/// <summary>LossClass → 그래프 엣지 가중치. 클수록 회피된다.</summary>
public static class LossWeights
{
/// <summary>홉마다 가산되는 페널티. 동일 품질이면 홉이 적은 경로를 선호하게 한다.</summary>
public const double HopPenalty = 0.1;
public static double Of(LossClass loss) => loss switch
{
LossClass.Lossless => 0.05,
LossClass.Container => 0.15,
LossClass.Recode => 0.50,
LossClass.Rasterize => 1.20,
_ => 0.50,
};
}
/// <summary>단일 변환 쌍 (입력 확장자 → 출력 확장자) + 품질 손실 등급.</summary>
public sealed record ConversionPair(string InputExtension, string OutputExtension, LossClass Loss = LossClass.Recode)
{
public static ConversionPair Of(string input, string output, LossClass loss = LossClass.Recode)
=> new(Normalize(input), Normalize(output), loss);
public static string Normalize(string ext)
{

View file

@ -59,17 +59,24 @@ public sealed record ProviderCapability(
.Distinct(StringComparer.OrdinalIgnoreCase);
}
public static IReadOnlyList<ConversionPair> PairsFromMatrix(IEnumerable<string> inputs, IEnumerable<string> outputs)
public static IReadOnlyList<ConversionPair> PairsFromMatrix(IEnumerable<string> inputs, IEnumerable<string> outputs, LossClass loss = LossClass.Recode)
{
var inputList = inputs.Select(ConversionPair.Normalize).ToList();
var outputList = outputs.Select(ConversionPair.Normalize).ToList();
var pairs = new List<ConversionPair>(inputList.Count * outputList.Count);
foreach (var i in inputList)
foreach (var o in outputList)
pairs.Add(new ConversionPair(i, o));
if (!string.Equals(i, o, StringComparison.OrdinalIgnoreCase)) // 동일포맷 자기쌍 제외 (png→png 재인코딩 회귀 방지)
pairs.Add(new ConversionPair(i, o, loss));
return pairs;
}
public static IReadOnlyList<ConversionPair> PairsToSingleOutput(IEnumerable<string> inputs, string output)
=> inputs.Select(i => ConversionPair.Of(i, output)).ToList();
public static IReadOnlyList<ConversionPair> PairsToSingleOutput(IEnumerable<string> inputs, string output, LossClass loss = LossClass.Recode)
{
var o = ConversionPair.Normalize(output);
return inputs.Select(ConversionPair.Normalize)
.Where(i => !string.Equals(i, o, StringComparison.OrdinalIgnoreCase))
.Select(i => new ConversionPair(i, o, loss))
.ToList();
}
}

View file

@ -9,6 +9,7 @@ public sealed class ProviderRegistry
= new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _allInputs = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _allOutputs = new(StringComparer.OrdinalIgnoreCase);
private readonly ConversionGraph _graph = new();
public ProviderRegistry(IEnumerable<IConverterProvider> providers)
{
@ -26,10 +27,14 @@ public sealed class ProviderRegistry
list.Add(pair.OutputExtension);
_allInputs.Add(pair.InputExtension);
_allOutputs.Add(pair.OutputExtension);
_graph.AddEdge(pair.InputExtension, pair.OutputExtension, provider, pair.Loss);
}
}
}
/// <summary>모든 Provider 능력으로부터 빌드된 변환 그래프. 엔진의 멀티홉 경로 탐색에 사용.</summary>
public ConversionGraph Graph => _graph;
public IReadOnlyList<IConverterProvider> All => _providers;
public IEnumerable<IConverterProvider> Implemented => _providers.Where(p => p.Capability.IsImplemented);

View file

@ -0,0 +1,210 @@
using System.IO;
using Everything2Everything.Core;
using Everything2Everything.Core.Providers;
using Xunit;
namespace Everything2Everything.Tests;
/// <summary>가짜 Provider — 그래프 경로 탐색 단위 테스트용. 실제 변환은 더미 파일 경로를 반환한다.</summary>
internal sealed class FakeProvider : IConverterProvider
{
public ProviderCapability Capability { get; }
public FakeProvider(string id, params ConversionPair[] pairs)
{
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)
=> Task.FromResult(ConvertResult.Ok(sourcePath,
new[] { Path.Combine(outputDirectory, Path.GetFileNameWithoutExtension(sourcePath) + outputExtension) }));
}
public class ConversionGraphTests
{
private static ConversionGraph Build(params (string from, string to, LossClass loss)[] edges)
{
var g = new ConversionGraph();
var p = new FakeProvider("fake");
foreach (var (from, to, loss) in edges)
g.AddEdge(from, to, p, loss);
return g;
}
[Fact]
public void DirectEdge_IsSingleHop()
{
var g = Build((".a", ".b", LossClass.Recode));
var path = g.FindBestPath(".a", ".b");
Assert.NotNull(path);
Assert.Single(path);
Assert.Equal(".a", path![0].From);
Assert.Equal(".b", path[0].To);
}
[Fact]
public void MultiHop_IsAutoComposed()
{
var g = Build((".a", ".b", LossClass.Recode), (".b", ".c", LossClass.Recode));
var path = g.FindBestPath(".a", ".c");
Assert.NotNull(path);
Assert.Equal(2, path!.Count);
Assert.Equal(".a", path[0].From);
Assert.Equal(".b", path[0].To);
Assert.Equal(".c", path[1].To);
}
[Fact]
public void NoPath_ReturnsNull()
{
var g = Build((".a", ".b", LossClass.Recode));
Assert.Null(g.FindBestPath(".a", ".z"));
Assert.Null(g.FindBestPath(".x", ".b"));
}
[Fact]
public void SameFormat_SelfEdge_IsReturned()
{
// pdf→pdf 압축 같은 동일포맷 self-edge는 경로로 반환된다.
var g = Build((".pdf", ".pdf", LossClass.Container));
var path = g.FindBestPath(".pdf", ".pdf");
Assert.NotNull(path);
Assert.Single(path);
Assert.Equal(".pdf", path![0].To);
}
[Fact]
public void SameFormat_WithoutSelfEdge_ReturnsNull()
{
// self-edge가 없으면 동일포맷은 경로 없음 → 엔진이 Skip 처리한다.
var g = Build((".a", ".b", LossClass.Recode));
Assert.Null(g.FindBestPath(".a", ".a"));
}
[Fact]
public void MaxHops_LimitsPathLength()
{
var g = Build((".a", ".b", LossClass.Recode), (".b", ".c", LossClass.Recode), (".c", ".d", LossClass.Recode));
Assert.NotNull(g.FindBestPath(".a", ".d", maxHops: 3));
Assert.Null(g.FindBestPath(".a", ".d", maxHops: 2));
}
[Fact]
public void AvoidLossy_SkipsRasterizeEdges()
{
// a→z 직접은 Rasterize, a→b→z는 무손실 경로. allowLossy=false면 후자만.
var g = Build(
(".a", ".z", LossClass.Rasterize),
(".a", ".b", LossClass.Lossless),
(".b", ".z", LossClass.Lossless));
var lossy = g.FindBestPath(".a", ".z", allowLossy: true);
Assert.NotNull(lossy);
var safe = g.FindBestPath(".a", ".z", maxHops: 3, allowLossy: false);
Assert.NotNull(safe);
Assert.Equal(2, safe!.Count); // 래스터 직접 엣지를 피해 우회
}
[Fact]
public void LowestLoss_PathIsPreferred()
{
// a→c 직접(Rasterize, 큰 손실) vs a→b→c(둘 다 Lossless). Dijkstra는 무손실 우회를 택한다.
var g = Build(
(".a", ".c", LossClass.Rasterize),
(".a", ".b", LossClass.Lossless),
(".b", ".c", LossClass.Lossless));
var path = g.FindBestPath(".a", ".c");
Assert.NotNull(path);
Assert.Equal(2, path!.Count); // 손실 적은 멀티홉 선호
}
[Fact]
public void ReachableOutputs_ComputesTransitiveClosure()
{
var g = Build((".a", ".b", LossClass.Recode), (".b", ".c", LossClass.Recode), (".c", ".d", LossClass.Recode));
var reach = g.ReachableOutputs(".a", maxHops: 3);
Assert.Contains(".b", reach);
Assert.Contains(".c", reach);
Assert.Contains(".d", reach);
}
[Fact]
public void HopConstrained_NotPoisonedByCheaperLongerRoute()
{
// 회귀: .a→.x→.b(무손실 2홉, 저비용)가 .b의 홉을 2로 오염시켜
// .a→.b→.goal(2홉) 유효 경로를 막던 false negative 방지. (적대적 리뷰 confirmed #1)
var g = Build(
(".a", ".x", LossClass.Lossless),
(".x", ".b", LossClass.Lossless),
(".a", ".b", LossClass.Recode),
(".b", ".goal", LossClass.Recode));
var path = g.FindBestPath(".a", ".goal", maxHops: 2);
Assert.NotNull(path);
Assert.Equal(2, path!.Count);
Assert.Equal(".a", path[0].From);
Assert.Equal(".b", path[0].To);
Assert.Equal(".goal", path[1].To);
}
[Fact]
public void ReachableOutputs_AllHaveFindablePath()
{
// 불변식: ReachableOutputs가 반환한 모든 출력은 FindBestPath로 실제 경로가 나와야 한다. (confirmed #2)
var g = Build(
(".a", ".b", LossClass.Recode),
(".b", ".c", LossClass.Recode),
(".a", ".x", LossClass.Lossless),
(".x", ".c", LossClass.Lossless),
(".c", ".d", LossClass.Recode));
foreach (var outExt in g.ReachableOutputs(".a", maxHops: 3))
Assert.NotNull(g.FindBestPath(".a", outExt, maxHops: 3));
}
[Fact]
public void PairsFromMatrix_ExcludesSelfPairs()
{
// 회귀: 자기쌍(png→png)이 제외되어 동일포맷 재인코딩이 발생하지 않는다. (confirmed #8)
var pairs = ProviderCapability.PairsFromMatrix(
new[] { ".png", ".jpg" }, new[] { ".png", ".jpg", ".webp" });
Assert.DoesNotContain(pairs, p => p.InputExtension == p.OutputExtension);
Assert.Contains(pairs, p => p.InputExtension == ".png" && p.OutputExtension == ".webp");
}
}
public class RegistryGraphIntegrationTests
{
[Fact]
public void DefaultEngine_BuildsGraph_WithKnownEdges()
{
var engine = Everything2EverythingBootstrap.CreateDefault();
var graph = engine.Providers.Graph;
// PdfToolProvider가 pdf→pdf 압축 엣지를 등록했는지 (P1 신규)
var pdfCompress = graph.FindBestPath(".pdf", ".pdf");
Assert.NotNull(pdfCompress);
// 기존 변환 능력이 그래프에 반영되는지 (png은 입력 노드로 존재)
Assert.True(graph.HasNode(".png"));
}
[Fact]
public void DefaultEngine_PngReachesPdf()
{
var engine = Everything2EverythingBootstrap.CreateDefault();
var graph = engine.Providers.Graph;
// png→pdf 경로(직접 또는 멀티홉)가 존재해야 한다.
var path = graph.FindBestPath(".png", ".pdf", maxHops: 3);
Assert.NotNull(path);
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<UseWPF>true</UseWPF>
<NoWarn>$(NoWarn);NU1901;NU1902;NU1903;NU1904</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Everything2Everything.Core\Everything2Everything.Core.csproj" />
</ItemGroup>
</Project>