ProviderRegistry.OutputsForInput을 그래프 transitive closure로 전환. UI 코드 변경 0으로 멀티홉 변환(json→xlsx, svg→jpg 등)이 사이드바·카스케이드 메뉴에 자동 노출된다. 동일 포맷 self-edge는 제외. - DirectOutputsForInput(1홉)도 별도 보존 - 카스케이드 메뉴는 PopularOutputs 교집합이라 폭발 없이 풍부해짐 - 테스트 1개 추가 (멀티홉 노출 검증) — 총 38개 통과 - SSOT P6 in_progress
226 lines
8.3 KiB
C#
226 lines
8.3 KiB
C#
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);
|
|
}
|
|
|
|
[Fact]
|
|
public void OutputsForInput_ExposesMultiHopReachable()
|
|
{
|
|
var reg = Everything2EverythingBootstrap.CreateDefault().Providers;
|
|
|
|
var jsonOut = reg.OutputsForInput(".json");
|
|
Assert.Contains(".csv", jsonOut); // 직접
|
|
Assert.Contains(".xlsx", jsonOut); // 멀티홉 json→csv→xlsx
|
|
Assert.DoesNotContain(".json", jsonOut); // 동일 포맷 self 제외
|
|
|
|
var svgOut = reg.OutputsForInput(".svg");
|
|
Assert.Contains(".png", svgOut); // 직접
|
|
Assert.Contains(".jpg", svgOut); // 멀티홉 svg→png→jpg
|
|
Assert.Contains(".pdf", svgOut); // 직접
|
|
}
|
|
}
|