using System.IO.Compression;
using System.Text;
using GitConverter.Lib.Converters;
using GitConverter.Lib.Factories;
using GitConverter.Lib.Models;
namespace GitConverter.TestsApp.Factories
{
///
/// Unit tests for ConverterFactoryInputExtensions (single-file JSON / extension detection).
///
///
/// Test scope:
/// - Verifies the input-based detection logic implemented by ConverterFactoryInputExtensions.TryCreateForInput.
/// - Exercises single-file JSON classification (GeoJson, EsriJson, TopoJson, GeoJsonSeq) and archive JSON voting.
/// - Covers defensive validation cases: empty files, files without extensions, truncated/corrupted files and ambiguous archives.
///
/// Test strategy:
/// - Use a lightweight FakeFactory implementing IConverterFactory.TryCreate to capture which converter key the detector requests.
/// - Create small temporary files and zip archives under a unique per-test folder in the system temp directory.
/// - Keep tests deterministic and self-contained; clean up created files/folders in Dispose.
///
/// Notes:
/// - These are unit tests for detection heuristics only and do not invoke Aspose or heavy converters.
/// - Assertions focus on the returned boolean, the converter key requested via FakeFactory.LastRequestedKey and
/// stable substrings of the detectReason to avoid brittle wording checks.
///
public class ConverterFactoryInputExtensionsTests : IDisposable
{
private readonly string _tmpFolder;
public ConverterFactoryInputExtensionsTests()
{
_tmpFolder = Path.Combine(Path.GetTempPath(), "GitConverter.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tmpFolder);
}
public void Dispose()
{
try { if (Directory.Exists(_tmpFolder)) Directory.Delete(_tmpFolder, true); } catch { }
}
private string CreateTempFile(string extension, string content)
{
var path = Path.Combine(_tmpFolder, Guid.NewGuid().ToString("N") + extension);
File.WriteAllText(path, content ?? string.Empty, Encoding.UTF8);
return path;
}
private string CreateTempFileNoExtension(string content)
{
var path = Path.Combine(_tmpFolder, Guid.NewGuid().ToString("N"));
File.WriteAllText(path, content ?? string.Empty, Encoding.UTF8);
return path;
}
private string CreateZeroByteFile()
{
var path = Path.Combine(_tmpFolder, Guid.NewGuid().ToString("N"));
File.WriteAllBytes(path, Array.Empty());
return path;
}
private string CreateZipWithEntries(params (string Name, string Content)[] entries)
{
var zipPath = Path.Combine(_tmpFolder, Guid.NewGuid().ToString("N") + ".zip");
using (var fs = File.Create(zipPath))
using (var za = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: false))
{
foreach (var e in entries)
{
var ze = za.CreateEntry(e.Name, CompressionLevel.Fastest);
using (var s = ze.Open())
using (var sw = new StreamWriter(s, Encoding.UTF8))
{
sw.Write(e.Content ?? string.Empty);
}
}
}
return zipPath;
}
private class FakeFactory : IConverterFactory
{
public string LastRequestedKey { get; private set; }
public IConverter CreatedConverter { get; private set; }
public IConverter Create(string formatOption) => throw new KeyNotFoundException();
public bool TryCreate(string formatOption, out IConverter converter)
{
LastRequestedKey = formatOption;
converter = new DummyConverter(formatOption);
CreatedConverter = converter;
return true;
}
public System.Collections.Generic.IReadOnlyCollection GetSupportedOptions() =>
new string[0];
}
private class DummyConverter : IConverter
{
public string Option { get; }
public DummyConverter(string option) { Option = option; }
public ConversionResult Convert(string gisInputFilePath, string gisTargetFormatOption, string outputFolderPath, string tempFolderPath)
{
return ConversionResult.Success("ok");
}
}
///
/// Verify that files with explicit .geojson extension map to the GeoJson converter key.
///
///
/// Purpose: ensure extension-based fast-path mapping works for .geojson files.
/// Behavior: TryCreateForInput should return true, request the "GeoJson" converter and provide a mapping reason.
///
[Fact(DisplayName = "Explicit .geojson extension maps to GeoJson converter")]
public void GeoJson_Extension_Mapped()
{
var f = new FakeFactory();
var file = CreateTempFile(".geojson", "{ \"type\": \"FeatureCollection\", \"features\": [] }");
var ok = f.TryCreateForInput(file, out var conv, out var reason);
Assert.True(ok);
Assert.NotNull(conv);
Assert.Equal("GeoJson", f.LastRequestedKey, ignoreCase: true);
Assert.Contains("Mapped extension", reason, StringComparison.OrdinalIgnoreCase);
}
///
/// Verify that files with explicit .esrijson extension map to the EsriJson converter key.
///
///
/// Purpose: ensure extension-based fast-path mapping works for .esrijson.
/// Behavior: TryCreateForInput should return true and request "EsriJson".
///
[Fact(DisplayName = "Explicit .esrijson extension maps to EsriJson converter")]
public void EsriJson_Extension_Mapped()
{
var f = new FakeFactory();
var file = CreateTempFile(".esrijson", "{ \"spatialReference\": { \"wkid\": 4326 } }");
var ok = f.TryCreateForInput(file, out var conv, out var reason);
Assert.True(ok);
Assert.NotNull(conv);
Assert.Equal("EsriJson", f.LastRequestedKey, ignoreCase: true);
Assert.Contains("Mapped extension", reason, StringComparison.OrdinalIgnoreCase);
}
///
/// Ensure FeatureCollection JSON (single-file) is detected as GeoJson.
///
///
/// Purpose: validate JSON content-based detection for a typical GeoJSON structure.
/// Behavior: TryCreateForInput should detect GeoJson and request that converter.
///
[Fact(DisplayName = "Generic .json with FeatureCollection detected as GeoJson")]
public void Json_FeatureCollection_Detected_As_GeoJson()
{
var f = new FakeFactory();
var file = CreateTempFile(".json", "{ \"type\": \"FeatureCollection\", \"features\": [] }");
var ok = f.TryCreateForInput(file, out var conv, out var reason);
Assert.True(ok);
Assert.NotNull(conv);
Assert.Equal("GeoJson", f.LastRequestedKey, ignoreCase: true);
Assert.Contains("Detected JSON format", reason, StringComparison.OrdinalIgnoreCase);
}
///
/// Ensure JSON containing spatialReference is detected as EsriJson.
///
///
/// Purpose: validate EsriJSON fingerprint detection using "spatialReference".
/// Behavior: TryCreateForInput should detect EsriJson and request that converter.
///
[Fact(DisplayName = "Generic .json with spatialReference detected as EsriJson")]
public void Json_With_SpatialReference_Detected_As_EsriJson()
{
var f = new FakeFactory();
var file = CreateTempFile(".json", "{ \"spatialReference\": { \"wkid\": 3857 }, \"features\": [] }");
var ok = f.TryCreateForInput(file, out var conv, out var reason);
Assert.True(ok);
Assert.NotNull(conv);
Assert.Equal("EsriJson", f.LastRequestedKey, ignoreCase: true);
Assert.Contains("Detected JSON format", reason, StringComparison.OrdinalIgnoreCase);
}
///
/// Verify NDJSON (newline-delimited JSON) detection for .json files containing multiple JSON objects separated by newlines.
///
///
/// Purpose: ensure NDJSON / GeoJSON sequence detection works when multiple JSON-like lines are present.
/// Behavior: TryCreateForInput should detect GeoJsonSeq and request that converter key.
///
[Fact(DisplayName = "NDJSON (.json with multiple JSON lines) detected as GeoJsonSeq")]
public void Json_Ndjson_Detected_As_GeoJsonSeq()
{
var f = new FakeFactory();
var content = "{\"type\":\"Feature\",\"properties\":{}}\n{\"type\":\"Feature\",\"properties\":{}}\n";
var file = CreateTempFile(".json", content);
var ok = f.TryCreateForInput(file, out var conv, out var reason);
Assert.True(ok);
Assert.NotNull(conv);
Assert.Equal("GeoJsonSeq", f.LastRequestedKey, ignoreCase: true);
// The detector reason can come from the JsonFormatDetector or from header sniffing.
// Assert on the stable detected key instead of a specific phrase like "NDJSON".
Assert.Contains("GeoJsonSeq", reason, StringComparison.OrdinalIgnoreCase);
}
///
/// Verify TopoJSON fingerprint detection from a .json header.
///
///
/// Purpose: ensure TopoJSON detection via presence of topology-related keywords.
/// Behavior: TryCreateForInput should request "TopoJson".
///
[Fact(DisplayName = "TopoJSON fingerprint detected from .json header")]
public void Json_TopoJson_Detected_As_TopoJson()
{
var f = new FakeFactory();
var file = CreateTempFile(".json", "{ \"type\": \"Topology\", \"topology\": {} }");
var ok = f.TryCreateForInput(file, out var conv, out var reason);
Assert.True(ok);
Assert.NotNull(conv);
Assert.Equal("TopoJson", f.LastRequestedKey, ignoreCase: true);
Assert.Contains("Detected JSON format", reason, StringComparison.OrdinalIgnoreCase);
}
///
/// Empty or zero-byte files are rejected by the detector.
///
///
/// Purpose:
/// - Validate that the detection code performs a file-size check and fails early for zero-byte files.
///
/// Expected behavior:
/// - TryCreateForInput returns false, converter is null, and detectReason contains an explanatory phrase.
///
[Fact(DisplayName = "Empty (zero-byte) file is rejected")]
public void EmptyFile_IsRejected()
{
var f = new FakeFactory();
var file = CreateZeroByteFile();
var ok = f.TryCreateForInput(file, out var conv, out var reason);
Assert.False(ok);
Assert.Null(conv);
Assert.Contains("file is empty", reason, StringComparison.OrdinalIgnoreCase);
}
///
/// Files without extensions are rejected by the detector.
///
///
/// Purpose:
/// - Ensure that inputs lacking an extension do not silently proceed to ambiguous detection.
///
/// Expected behavior:
/// - TryCreateForInput returns false and the detectReason indicates a missing extension.
///
[Fact(DisplayName = "File without extension is rejected")]
public void FileWithoutExtension_IsRejected()
{
var f = new FakeFactory();
var file = CreateTempFileNoExtension("{ \"type\": \"FeatureCollection\" }");
var ok = f.TryCreateForInput(file, out var conv, out var reason);
Assert.False(ok);
Assert.Null(conv);
Assert.Contains("no extension", reason, StringComparison.OrdinalIgnoreCase);
}
///
/// Archives containing only unknown/minimal JSON entries should fail detection (no winner).
///
///
/// Purpose:
/// - Validate archive JSON voting behavior when entries lack distinguishing fingerprints.
///
/// Expected behavior:
/// - TryCreateForInput returns false and detectReason indicates no classification or ambiguity.
///
[Fact(DisplayName = "Archive with only unknown JSON entries fails detection")]
public void Archive_With_UnknownJsonEntries_Fails()
{
var f = new FakeFactory();
// small JSON blobs that lack distinguishing fingerprints
var zip = CreateZipWithEntries(
("a.json", "{ \"foo\": \"bar\" }"),
("b.json", "{ \"x\": 1 }")
);
var ok = f.TryCreateForInput(zip, out var conv, out var reason);
Assert.False(ok);
Assert.Null(conv);
Assert.True(reason != null &&
(reason.IndexOf("no json entries", StringComparison.OrdinalIgnoreCase) >= 0
|| reason.IndexOf("ambiguous", StringComparison.OrdinalIgnoreCase) >= 0),
$"Unexpected detectReason: {reason}");
}
///
/// When JSON voting produces a tie the deterministic tiebreaker is applied; EsriJson is preferred over GeoJson.
///
///
/// Purpose:
/// - Ensure the tie-resolution logic is deterministic and favors more specific formats.
///
/// Test construction:
/// - Create a zip with two JSON entries: one that fingerprints as EsriJson and one as GeoJson.
/// - Both entries are padded to exceed the header-sniffing minimum so heuristics can match.
///
/// Expected behavior:
/// - TryCreateForInput returns true and the factory was asked for "EsriJson".
///
[Fact(DisplayName = "Archive with tied JSON votes uses tiebreaker (EsriJson preferred)")]
public void Archive_TiedJsonVotes_Tiebreaker()
{
var f = new FakeFactory();
string MakeLarge(string marker)
{
var sb = new StringBuilder();
sb.Append("{");
sb.Append($"\"{marker}\": true,");
while (sb.Length < 700)
{
sb.Append("\"padding\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",");
}
sb.Append("\"end\":true}");
return sb.ToString();
}
var esriJsonContent = MakeLarge("spatialReference"); // Esri marker
var geoJsonContent = MakeLarge("FeatureCollection"); // GeoJSON marker
var zip = CreateZipWithEntries(
("esri.json", esriJsonContent),
("geo.json", geoJsonContent)
);
var ok = f.TryCreateForInput(zip, out var conv, out var reason);
Assert.True(ok, $"Expected detection to succeed via tiebreaker; reason: {reason}");
Assert.NotNull(conv);
Assert.Equal("EsriJson", f.LastRequestedKey, ignoreCase: true);
}
///
/// Corrupted or truncated JSON files should not be misclassified.
///
///
/// Purpose:
/// - Provide a syntactically truncated JSON and confirm detection fails gracefully.
///
/// Expected behavior:
/// - TryCreateForInput returns false and detectReason indicates inability to determine JSON format.
///
[Fact(DisplayName = "Corrupted / truncated JSON file is rejected")]
public void Corrupted_Truncated_Json_IsRejected()
{
var f = new FakeFactory();
var file = CreateTempFile(".json", "{ \"type\": \"FeatureCollection\", \"features\": [ { \"type\": \"Feature\" "); // truncated
var ok = f.TryCreateForInput(file, out var conv, out var reason);
Assert.False(ok);
Assert.Null(conv);
Assert.Contains("could not be determined", reason, StringComparison.OrdinalIgnoreCase);
}
///
/// Minified (no-whitespace) but sufficiently large GeoJSON should still be detected as GeoJson.
///
///
/// Purpose:
/// - Ensure header-based substring heuristics work on minified JSON if the identifying tokens are present
/// and the header length is large enough for reliable classification.
///
/// Expected behavior:
/// - TryCreateForInput returns true and the factory was asked for "GeoJson".
///
[Fact(DisplayName = "Large minified GeoJSON (no whitespace) is classified as GeoJson")]
public void Minified_Large_Json_Detected_As_GeoJson()
{
var f = new FakeFactory();
// Build minified featurecollection with many features to exceed the classifier threshold.
var sb = new StringBuilder();
sb.Append("{\"type\":\"FeatureCollection\",\"features\":[");
for (int i = 0; i < 40; i++)
{
sb.Append("{\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[");
sb.Append(i);
sb.Append(",");
sb.Append(i + 0.1);
sb.Append("]},\"properties\":{\"id\":");
sb.Append(i);
sb.Append("}}");
if (i < 39) sb.Append(",");
}
sb.Append("]}");
var minified = sb.ToString();
var file = CreateTempFile(".json", minified);
var ok = f.TryCreateForInput(file, out var conv, out var reason);
Assert.True(ok, $"Expected minified large GeoJSON to be detected. Reason: {reason}");
Assert.NotNull(conv);
Assert.Equal("GeoJson", f.LastRequestedKey, ignoreCase: true);
}
}
}