This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
using System.Text;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
using OpenMcdf;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class AnalysisTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("3DS/LIMBO/BLUEROMS.WS.html")]
|
||||
[InlineData("3DS/World of Goo/download.htm")]
|
||||
public void WebPagesAreSupportArtifactsRatherThanGames(string path)
|
||||
{
|
||||
var result = Assert.IsType<NonGamePath>(LibraryContentPolicy.IdentifyNonGamePath(path));
|
||||
Assert.Equal("NonGameSupport", result.Category);
|
||||
Assert.StartsWith("web-page:", result.Evidence);
|
||||
}
|
||||
[Theory]
|
||||
[InlineData("roms/Tools/Switch/prod.keys")]
|
||||
[InlineData("PC/.gameyfin/cache/cover.jpg")]
|
||||
[InlineData("Game/runtime/libSceAudioOut.prx")]
|
||||
[InlineData("roms/PS2/BIOS/scph39001.bin")]
|
||||
[InlineData("roms/Switch/keys/prod.keys")]
|
||||
[InlineData("PC/Game/manuals/manual.pdf")]
|
||||
[InlineData("roms/N64/Game/readme.txt")]
|
||||
public void NonGameDirectoriesAreDeterministicallyIdentified(string path)
|
||||
{
|
||||
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath(path));
|
||||
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/FMT-v23.23.2/FMT/FMT.exe"));
|
||||
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Steam/steam.exe"));
|
||||
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Game DLC Unlocker/tool.exe"));
|
||||
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Steam/steamapps/common/tool.dll"));
|
||||
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("roms/Tools/Emulators/retroarch.exe"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Super Mario 64 (USA) (Rev 1).z64", "Super Mario 64")]
|
||||
[InlineData("Asterix.and.Obelix.Mission.Babylon-DUPLEX", "Asterix and Obelix Mission Babylon")]
|
||||
[InlineData("Fallout 4 [FitGirl Repack]", "Fallout 4")]
|
||||
[InlineData("Metal Gear Solid (Disc 2)", "Metal Gear Solid")]
|
||||
[InlineData("0002 - Need for Speed - Underground 2 (USA).zip", "Need for Speed - Underground 2")]
|
||||
[InlineData("Animal Crossing- City Folk [RUUE01].wbfs", "Animal Crossing- City Folk")]
|
||||
[InlineData("Pokemon Diamond USA NDS-LGC.nds", "Pokemon Diamond")]
|
||||
[InlineData("Asterix and Obelix Mission Babylon PS5", "Asterix and Obelix Mission Babylon")]
|
||||
[InlineData("Assassins's Creed 4 - Black Flag", "Assassin's Creed 4 - Black Flag")]
|
||||
[InlineData("Call of Duty - Black Ops 6 PS5 iNTERNAL-PS5B", "Call of Duty - Black Ops 6")]
|
||||
[InlineData("Crash Bandicoot - The Wrath of Cortex (USA) (v1", "Crash Bandicoot - The Wrath of Cortex")]
|
||||
[InlineData("Crash Team Racing [U] [SCUS-94426]", "Crash Team Racing")]
|
||||
[InlineData("Burnout Legends", "Burnout Legends")]
|
||||
[InlineData("The Darkness", "The Darkness")]
|
||||
public void CanonicalTitlesRemovePackagingNoise(string raw, string expected)
|
||||
{
|
||||
Assert.Equal(expected, LibraryContentPolicy.CanonicalGameTitle(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RomHeaderEvidenceNeverContainsDatabaseInvalidControlCharacters()
|
||||
{
|
||||
var header = new byte[0xC0];
|
||||
"TEST\0TITLE"u8.CopyTo(header.AsSpan(0xA0));
|
||||
header[0xB2] = 0x96;
|
||||
var result = ArtifactAnalysis.Classify("synthetic.gba", header);
|
||||
Assert.DoesNotContain(result.Evidence, item => item.Value.Any(char.IsControl));
|
||||
}
|
||||
[Fact]
|
||||
public async Task HashesAreStreamedAndMatchKnownVectors()
|
||||
{
|
||||
await using var stream = new MemoryStream("abc"u8.ToArray());
|
||||
var result = await ArtifactAnalysis.HashAsync(stream, true, CancellationToken.None);
|
||||
Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", result.Sha256);
|
||||
Assert.Equal("a9993e364706816aba3e25717850c26c9cd0d89d", result.Sha1);
|
||||
Assert.Equal("c2412435", result.Crc32);
|
||||
Assert.Equal(3, result.BytesRead);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("game.nes", new byte[] { 0x4e, 0x45, 0x53, 0x1a }, MediaType.Rom, Confidence.Deterministic)]
|
||||
[InlineData("setup.exe", new byte[] { 0x4d, 0x5a }, MediaType.WindowsPackage, Confidence.Deterministic)]
|
||||
[InlineData("archive.zip", new byte[] { 0x50, 0x4b, 0x03, 0x04 }, MediaType.Archive, Confidence.Deterministic)]
|
||||
[InlineData("mystery.bin", new byte[] { 0x00 }, MediaType.Unknown, Confidence.None)]
|
||||
public void ClassificationUsesSignatureEvidence(string name, byte[] header, MediaType expected, Confidence confidence)
|
||||
{
|
||||
var result = ArtifactAnalysis.Classify(name, header);
|
||||
Assert.Equal(expected, result.MediaType); Assert.Equal(confidence, result.Confidence);
|
||||
Assert.Equal(expected != MediaType.Unknown, result.Supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CueParserHandlesQuotedAndUnquotedReferences()
|
||||
{
|
||||
var refs = ArtifactAnalysis.ParseCueReferences("FILE \"Disc 1.bin\" BINARY\n FILE Disc2.bin BINARY");
|
||||
Assert.Equal(["Disc 1.bin", "Disc2.bin"], refs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M3uParserPreservesOrderAndIgnoresComments()
|
||||
{
|
||||
Assert.Equal(["Disc 2.cue", "Disc 1.cue"], ArtifactAnalysis.ParseM3uReferences("#EXTM3U\nDisc 2.cue\nDisc 1.cue"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../outside.bin", false)]
|
||||
[InlineData("tracks/disc.bin", true)]
|
||||
[InlineData("/absolute.bin", false)]
|
||||
public void DescriptorReferencesCannotEscapeRoot(string path, bool safe) => Assert.Equal(safe, ArtifactAnalysis.IsSafeRelativeReference(path));
|
||||
|
||||
[Fact]
|
||||
public void ScanStateMachineRejectsInvalidTransitions()
|
||||
{
|
||||
var run = new ScanRun(Guid.NewGuid(), Guid.NewGuid(), ScanMode.Quick, ScanState.Queued, "Queued", 0, null, 0, null, null, false, DateTimeOffset.UtcNow);
|
||||
Assert.Equal(ScanState.Verifying, run.Transition(ScanState.Verifying, "Verify").State);
|
||||
Assert.Throws<InvalidOperationException>(() => run.Transition(ScanState.Completed, "Complete"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LibraryRequiresAbsolutePath() => Assert.Throws<ArgumentException>(() => LibraryRoot.Create("ROMs", "relative", LibraryKind.Rom));
|
||||
|
||||
[Fact]
|
||||
public void PeInspectionReadsArchitectureWithoutExecutingImage()
|
||||
{
|
||||
using var stream = File.OpenRead(typeof(AnalysisTests).Assembly.Location);
|
||||
var result = WindowsPackageAnalysis.InspectPe(stream);
|
||||
Assert.True(result.IsManaged);
|
||||
Assert.True(result.Kind is "library" or "executable");
|
||||
Assert.NotEqual(Confidence.None, result.Confidence);
|
||||
Assert.Contains("ProductVersion", result.VersionInfo.Keys);
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "pe.version.ProductVersion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumberedInstallerPayloadGapsAreDetected() =>
|
||||
Assert.Equal([2], WindowsPackageAnalysis.MissingNumberedPayloads(["setup-1.bin", "setup-3.bin"]));
|
||||
|
||||
[Fact]
|
||||
public void MsiSummaryInformationIsReadWithoutInvokingWindowsInstaller()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ludarium-{Guid.NewGuid():N}.msi");
|
||||
try
|
||||
{
|
||||
using (var root = RootStorage.Create(path))
|
||||
using (var summary = root.CreateStream("\u0005SummaryInformation"))
|
||||
summary.Write(CreateSummaryPropertySet((2, "Synthetic Installer"), (4, "Ludarium Tests"),
|
||||
(7, "x64;1033"), (9, "{11111111-2222-3333-4444-555555555555}")));
|
||||
|
||||
var result = WindowsPackageAnalysis.InspectMsiSummary(path);
|
||||
|
||||
Assert.Equal("Synthetic Installer", result["Title"]);
|
||||
Assert.Equal("Ludarium Tests", result["Author"]);
|
||||
Assert.Equal("x64", result["Architecture"]);
|
||||
Assert.Equal("1033", result["Language"]);
|
||||
Assert.Equal("{11111111-2222-3333-4444-555555555555}", result["PackageCode"]);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("game.z64", new byte[] { 0x80, 0x37, 0x12, 0x40 }, "n64")]
|
||||
[InlineData("game.v64", new byte[] { 0x37, 0x80, 0x40, 0x12 }, "n64")]
|
||||
[InlineData("game.n64", new byte[] { 0x40, 0x12, 0x37, 0x80 }, "n64")]
|
||||
[InlineData("game.gba", new byte[] { 0, 0, 0, 0, 0x24, 0xff, 0xae, 0x51 }, "gba")]
|
||||
public void RomHeadersOverrideExtensionHints(string name, byte[] header, string platform)
|
||||
{
|
||||
var result = ArtifactAnalysis.Classify(name, header);
|
||||
Assert.Equal(MediaType.Rom, result.MediaType);
|
||||
Assert.Equal(platform, result.Platform);
|
||||
Assert.Equal(Confidence.Deterministic, result.Confidence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("game.cso", new byte[] { 0x43, 0x49, 0x53, 0x4f }, "psp", "PSP CSO")]
|
||||
[InlineData("game.nsp", new byte[] { 0x50, 0x46, 0x53, 0x30 }, "switch", "Switch PFS0")]
|
||||
public void ModernContainerSignaturesProvideDeterministicPlatformEvidence(string name, byte[] header,
|
||||
string platform, string signature)
|
||||
{
|
||||
var result = ArtifactAnalysis.Classify(name, header);
|
||||
|
||||
Assert.Equal(MediaType.DiscImage, result.MediaType);
|
||||
Assert.Equal(platform, result.Platform);
|
||||
Assert.Equal(Confidence.Deterministic, result.Confidence);
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "signature" && item.Value == signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwitchXciHeaderIsDetectedAtItsSpecifiedOffset()
|
||||
{
|
||||
var header = new byte[0x104];
|
||||
"HEAD"u8.CopyTo(header.AsSpan(0x100));
|
||||
|
||||
var result = ArtifactAnalysis.Classify("game.xci", header);
|
||||
|
||||
Assert.Equal("switch", result.Platform);
|
||||
Assert.Equal(Confidence.Deterministic, result.Confidence);
|
||||
Assert.Contains(result.Evidence, item => item.Value == "Switch XCI");
|
||||
|
||||
var misplaced = ArtifactAnalysis.Classify("roms/PS2/game.xci", header, "ps2");
|
||||
Assert.Equal("switch", misplaced.Platform);
|
||||
Assert.Contains(misplaced.Evidence, item => item.Kind == "platform.conflict" &&
|
||||
item.Value == "directory:ps2;signature:switch");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("psx")]
|
||||
[InlineData("ps2")]
|
||||
[InlineData("psp")]
|
||||
public async Task Iso9660ContentsIdentifyPlayStationFamiliesWithoutDirectoryHints(string expectedPlatform)
|
||||
{
|
||||
await using var iso = new MemoryStream(CreatePlayStationIso(expectedPlatform));
|
||||
|
||||
var result = await DiscImageAnalysis.InspectIsoAsync(iso, CancellationToken.None);
|
||||
|
||||
Assert.Equal(expectedPlatform, result.Platform);
|
||||
Assert.Equal(Confidence.Deterministic, result.Confidence);
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "disc.signature" && item.Value == "ISO9660-CD001");
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "disc.platform" && item.Value.StartsWith(expectedPlatform + ":", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TruncatedOrGenericIsoNeverInventsAPlatform()
|
||||
{
|
||||
await using var truncated = new MemoryStream(new byte[1024]);
|
||||
Assert.Null((await DiscImageAnalysis.InspectIsoAsync(truncated, CancellationToken.None)).Platform);
|
||||
|
||||
await using var generic = new MemoryStream(CreatePlayStationIso(null));
|
||||
var result = await DiscImageAnalysis.InspectIsoAsync(generic, CancellationToken.None);
|
||||
Assert.Null(result.Platform);
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "disc.signature");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "psp")]
|
||||
[InlineData(true, "psx")]
|
||||
public async Task PbpPayloadDeterministicallyDistinguishesPspHomebrewFromPsx(bool psx, string expected)
|
||||
{
|
||||
var bytes = new byte[96];
|
||||
"\0PBP"u8.CopyTo(bytes);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4, 4), 0x00010000);
|
||||
var offsets = psx
|
||||
? new uint[] { 40, 40, 40, 40, 40, 40, 40, 64 }
|
||||
: new uint[] { 40, 40, 40, 40, 40, 40, 40, 96 };
|
||||
for (var index = 0; index < offsets.Length; index++)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8 + index * 4, 4), offsets[index]);
|
||||
if (psx) "PSISOIMG"u8.CopyTo(bytes.AsSpan(64));
|
||||
else new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F' }.CopyTo(bytes.AsSpan(40));
|
||||
|
||||
await using var stream = new MemoryStream(bytes);
|
||||
var result = await DiscImageAnalysis.InspectPbpAsync(stream, CancellationToken.None);
|
||||
|
||||
Assert.Equal(expected, result.Platform);
|
||||
Assert.Equal(Confidence.Deterministic, result.Confidence);
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "disc.signature" && item.Value == "PBP");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReproduciblePspHomebrewFixturePassesTheExactPbpInspector()
|
||||
{
|
||||
await using var compressed = File.OpenRead(Path.Combine(AppContext.BaseDirectory, "fixtures", "ludarium-psp-fixture.pbp.gz"));
|
||||
await using var gzip = new GZipStream(compressed, CompressionMode.Decompress);
|
||||
await using var fixture = new MemoryStream();
|
||||
await gzip.CopyToAsync(fixture);
|
||||
fixture.Position = 0;
|
||||
|
||||
var result = await DiscImageAnalysis.InspectPbpAsync(fixture, CancellationToken.None);
|
||||
|
||||
Assert.Equal(130008, fixture.Length);
|
||||
Assert.Equal("psp", result.Platform);
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "disc.platform" && item.Value == "psp:PBP:ELF");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PbpInspectorRejectsNonMonotonicOffsets()
|
||||
{
|
||||
var bytes = new byte[64];
|
||||
"\0PBP"u8.CopyTo(bytes);
|
||||
for (var index = 0; index < 8; index++)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8 + index * 4, 4), (uint)(40 + index));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(24, 4), 39);
|
||||
|
||||
await using var stream = new MemoryStream(bytes);
|
||||
var result = await DiscImageAnalysis.InspectPbpAsync(stream, CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Platform);
|
||||
Assert.Equal(Confidence.None, result.Confidence);
|
||||
Assert.Empty(result.Evidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NintendoDsHeaderProvidesSemanticEvidence()
|
||||
{
|
||||
var header = new byte[0x160];
|
||||
Encoding.ASCII.GetBytes("SYNTHETIC DS").CopyTo(header, 0);
|
||||
Encoding.ASCII.GetBytes("ALPE").CopyTo(header, 12);
|
||||
Encoding.ASCII.GetBytes("01").CopyTo(header, 16);
|
||||
header[18] = 0; header[30] = 2;
|
||||
new byte[] { 0x24, 0xff, 0xae, 0x51 }.CopyTo(header, 0xC0);
|
||||
|
||||
var result = ArtifactAnalysis.Classify("unknown.bin", header);
|
||||
|
||||
Assert.Equal("nds", result.Platform);
|
||||
Assert.Equal(Confidence.Deterministic, result.Confidence);
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "rom.title" && item.Value == "SYNTHETIC DS");
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "rom.gameCode" && item.Value == "ALPE");
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "rom.region" && item.Value == "usa");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x7FC0)]
|
||||
[InlineData(0x81C0)]
|
||||
public void SnesHeadersWithAndWithoutCopierPrefixAreDetected(int headerOffset)
|
||||
{
|
||||
var header = new byte[headerOffset + 0x20];
|
||||
Encoding.ASCII.GetBytes("SYNTHETIC SNES").CopyTo(header, headerOffset);
|
||||
header[headerOffset + 0x15] = 0x20;
|
||||
header[headerOffset + 0x19] = 1;
|
||||
BitConverter.GetBytes((ushort)0xEDCB).CopyTo(header, headerOffset + 0x1C);
|
||||
BitConverter.GetBytes((ushort)0x1234).CopyTo(header, headerOffset + 0x1E);
|
||||
|
||||
var result = ArtifactAnalysis.Classify("cartridge.dat", header);
|
||||
|
||||
Assert.Equal(MediaType.Rom, result.MediaType);
|
||||
Assert.Equal("snes", result.Platform);
|
||||
Assert.Contains(result.Evidence, item => item.Kind == "rom.title" && item.Value == "SYNTHETIC SNES");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidSnesChecksumDoesNotCreateFalseSignature()
|
||||
{
|
||||
var header = new byte[0x8000];
|
||||
header[0x7FD5] = 0x20;
|
||||
Assert.Equal(MediaType.Unknown, ArtifactAnalysis.Classify("unknown.dat", header).MediaType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Games", "/library/games", "PC/Example/setup.bin", "windows")]
|
||||
[InlineData("Games", "/library/games", "roms/3ds/Example/game.3ds", "3ds")]
|
||||
[InlineData("Games", "/library/games", "roms/PS1/Example/disc.cue", "psx")]
|
||||
[InlineData("Games", "/library/games", "roms/ngc/Example/game.rvz", "gamecube")]
|
||||
[InlineData("PS4 Games", "/library/ps4", "Example/game.pkg", "ps4")]
|
||||
[InlineData("PS5 Games", "/library/ps5", "Example/game.pkg", "ps5")]
|
||||
public void PlatformHintsFollowConfiguredDirectoryTaxonomy(string libraryName, string libraryPath, string relativePath, string expected) =>
|
||||
Assert.Equal(expected, ArtifactAnalysis.InferPlatformHint(libraryName, libraryPath, relativePath));
|
||||
|
||||
[Fact]
|
||||
public void DirectoryHintClassifiesPlatformWithoutOverridingDeterministicFormatEvidence()
|
||||
{
|
||||
var hinted = ArtifactAnalysis.Classify("roms/PS2/game.iso", ReadOnlySpan<byte>.Empty, "ps2");
|
||||
Assert.Equal("ps2", hinted.Platform);
|
||||
Assert.Contains(hinted.Evidence, evidence => evidence.Kind == "directory.platform" && evidence.Value == "ps2");
|
||||
|
||||
var signatureWins = ArtifactAnalysis.Classify("roms/PS2/misplaced.nes", new byte[] { 0x4e, 0x45, 0x53, 0x1a }, "ps2");
|
||||
Assert.Equal("nes", signatureWins.Platform);
|
||||
|
||||
var multipartVolume = ArtifactAnalysis.Classify("PS5-Release/part.v64", ReadOnlySpan<byte>.Empty, "ps5");
|
||||
Assert.Equal("ps5", multipartVolume.Platform);
|
||||
Assert.Equal(MediaType.Unknown, multipartVolume.MediaType);
|
||||
Assert.Contains(multipartVolume.Evidence, evidence => evidence.Kind == "platform.conflict");
|
||||
}
|
||||
|
||||
private static byte[] CreateSummaryPropertySet(params (int Id, string Value)[] properties)
|
||||
{
|
||||
var encoded = properties.Select(property =>
|
||||
{
|
||||
var value = Encoding.Latin1.GetBytes(property.Value + '\0');
|
||||
var length = 8 + value.Length;
|
||||
var padded = (length + 3) & ~3;
|
||||
var bytes = new byte[padded];
|
||||
BitConverter.GetBytes(30).CopyTo(bytes, 0);
|
||||
BitConverter.GetBytes(value.Length).CopyTo(bytes, 4);
|
||||
value.CopyTo(bytes, 8);
|
||||
return (property.Id, Bytes: bytes);
|
||||
}).ToArray();
|
||||
var sectionOffset = 48;
|
||||
var valuesOffset = 8 + encoded.Length * 8;
|
||||
var sectionSize = valuesOffset + encoded.Sum(item => item.Bytes.Length);
|
||||
var result = new byte[sectionOffset + sectionSize];
|
||||
BitConverter.GetBytes((ushort)0xfffe).CopyTo(result, 0);
|
||||
BitConverter.GetBytes(1).CopyTo(result, 24);
|
||||
BitConverter.GetBytes(sectionOffset).CopyTo(result, 44);
|
||||
BitConverter.GetBytes(sectionSize).CopyTo(result, sectionOffset);
|
||||
BitConverter.GetBytes(encoded.Length).CopyTo(result, sectionOffset + 4);
|
||||
var cursor = valuesOffset;
|
||||
for (var index = 0; index < encoded.Length; index++)
|
||||
{
|
||||
BitConverter.GetBytes(encoded[index].Id).CopyTo(result, sectionOffset + 8 + index * 8);
|
||||
BitConverter.GetBytes(cursor).CopyTo(result, sectionOffset + 12 + index * 8);
|
||||
encoded[index].Bytes.CopyTo(result, sectionOffset + cursor);
|
||||
cursor += encoded[index].Bytes.Length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] CreatePlayStationIso(string? platform)
|
||||
{
|
||||
const int sector = 2048;
|
||||
const int rootLba = 20;
|
||||
const int configLba = 22;
|
||||
var image = new byte[24 * sector];
|
||||
var pvd = image.AsSpan(16 * sector, sector);
|
||||
pvd[0] = 1;
|
||||
"CD001"u8.CopyTo(pvd[1..]);
|
||||
pvd[6] = 1;
|
||||
|
||||
var directory = image.AsSpan(rootLba * sector, sector);
|
||||
var directoryLength = 0;
|
||||
if (platform is "psp")
|
||||
{
|
||||
directoryLength += WriteIsoRecord(directory[directoryLength..], 21, sector, "PSP_GAME");
|
||||
directoryLength += WriteIsoRecord(directory[directoryLength..], 23, 32, "UMD_DATA.BIN;1");
|
||||
}
|
||||
else if (platform is "psx" or "ps2")
|
||||
{
|
||||
var config = platform == "ps2" ? "BOOT2 = cdrom0:\\\\SLUS_000.00;1\r\n" : "BOOT = cdrom:\\\\SCUS_000.00;1\r\n";
|
||||
var configBytes = Encoding.ASCII.GetBytes(config);
|
||||
configBytes.CopyTo(image, configLba * sector);
|
||||
directoryLength += WriteIsoRecord(directory[directoryLength..], configLba, configBytes.Length, "SYSTEM.CNF;1");
|
||||
}
|
||||
else
|
||||
{
|
||||
directoryLength += WriteIsoRecord(directory, 23, 16, "README.TXT;1");
|
||||
}
|
||||
WriteIsoRecord(pvd[156..], rootLba, directoryLength, "\0");
|
||||
return image;
|
||||
}
|
||||
|
||||
private static int WriteIsoRecord(Span<byte> destination, int extent, int dataLength, string name)
|
||||
{
|
||||
var nameBytes = Encoding.ASCII.GetBytes(name);
|
||||
var length = 33 + nameBytes.Length + (nameBytes.Length % 2 == 0 ? 1 : 0);
|
||||
destination[..length].Clear();
|
||||
destination[0] = (byte)length;
|
||||
BitConverter.GetBytes(extent).CopyTo(destination[2..]);
|
||||
BitConverter.GetBytes(BinaryPrimitives.ReverseEndianness(extent)).CopyTo(destination[6..]);
|
||||
BitConverter.GetBytes(dataLength).CopyTo(destination[10..]);
|
||||
BitConverter.GetBytes(BinaryPrimitives.ReverseEndianness(dataLength)).CopyTo(destination[14..]);
|
||||
destination[25] = 0;
|
||||
destination[28] = 1;
|
||||
destination[31] = 1;
|
||||
destination[32] = (byte)nameBytes.Length;
|
||||
nameBytes.CopyTo(destination[33..]);
|
||||
return length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.IO.Compression;
|
||||
using Ludarium.Application;
|
||||
using SharpCompress.Common;
|
||||
using SharpCompress.Writers;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class ArchiveAnalysisTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnumeratesCentralDirectoryWithoutExtractingMembers()
|
||||
{
|
||||
using var stream = CreateZip(("games/demo.nes", new byte[] { 1, 2, 3 }), ("manual.txt", "offline"u8.ToArray()));
|
||||
|
||||
var result = ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream);
|
||||
|
||||
Assert.True(result.Complete);
|
||||
Assert.Equal(2, result.Members.Count);
|
||||
Assert.Contains(result.Members, x => x.Path == "games/demo.nes" && x.UncompressedSize == 3 && x.ContentBlobId is null);
|
||||
Assert.Equal([0, 1], result.Members.Select(x => x.Sequence));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordsTraversalAsUnsafeAndNeverExtractsIt()
|
||||
{
|
||||
using var stream = CreateZip(("../escape.exe", new byte[] { 1 }));
|
||||
|
||||
var result = ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream);
|
||||
|
||||
Assert.False(result.Complete);
|
||||
Assert.True(Assert.Single(result.Members).UnsafePath);
|
||||
Assert.Contains(result.Findings, x => x.Contains("never extracted", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopsAtConfiguredMemberLimit()
|
||||
{
|
||||
using var stream = CreateZip(("one.bin", new byte[] { 1 }), ("two.bin", new byte[] { 2 }));
|
||||
|
||||
var result = ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream, new ArchiveLimits(MaxMembers: 1));
|
||||
|
||||
Assert.Single(result.Members);
|
||||
Assert.Contains(result.Findings, x => x.Contains("member limit", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HonorsCancellationBeforeEnumeratingMembers()
|
||||
{
|
||||
using var stream = CreateZip(("one.bin", new byte[] { 1 }));
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
Assert.Throws<OperationCanceledException>(() =>
|
||||
ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream, cancellationToken: cancellation.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumeratesSevenZipHeadersWithoutExtractingMembers()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ludarium-{Guid.NewGuid():N}.7z");
|
||||
try
|
||||
{
|
||||
using (var writer = WriterFactory.OpenWriter(path, ArchiveType.SevenZip, new WriterOptions(CompressionType.LZMA)))
|
||||
{
|
||||
using var first = new MemoryStream(new byte[] { 1, 2, 3 });
|
||||
writer.Write("games/demo.nes", first, null);
|
||||
using var second = new MemoryStream("offline"u8.ToArray());
|
||||
writer.Write("manual.txt", second, null);
|
||||
}
|
||||
using var stream = File.OpenRead(path);
|
||||
|
||||
var result = ArchiveAnalysis.InspectSevenZip(Guid.NewGuid(), stream);
|
||||
|
||||
Assert.True(result.Complete);
|
||||
Assert.Equal(2, result.Members.Count);
|
||||
Assert.Contains(result.Members, x => x.Path == "games/demo.nes" && x.UncompressedSize == 3);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
private static MemoryStream CreateZip(params (string Name, byte[] Data)[] entries)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var (name, data) in entries)
|
||||
{
|
||||
var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize);
|
||||
using var output = entry.Open();
|
||||
output.Write(data);
|
||||
}
|
||||
}
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Ludarium.Domain;
|
||||
using Ludarium.Application;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class ArtworkQualityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1526, 1370)]
|
||||
[InlineData(2100, 1536)]
|
||||
[InlineData(512, 521)]
|
||||
public void LandscapeOrSquareImagesAreNotCoverQuality(int width, int height)
|
||||
=> Assert.True(ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High) < ArtworkQuality.VerifiedThreshold);
|
||||
|
||||
[Fact]
|
||||
public void PortraitHighResolutionTrustedArtworkIsVerified() =>
|
||||
Assert.True(ArtworkQuality.Score(600, 900, 0.6667m, "LaunchBox", Confidence.High) >= ArtworkQuality.VerifiedThreshold);
|
||||
|
||||
[Theory]
|
||||
[InlineData("ps5", 1600, 2040)]
|
||||
[InlineData("switch", 1000, 1600)]
|
||||
[InlineData("nds", 1350, 1220)]
|
||||
[InlineData("3ds", 1300, 1160)]
|
||||
[InlineData("n64", 1400, 1000)]
|
||||
[InlineData("snes", 1400, 1000)]
|
||||
[InlineData("gb", 1000, 1000)]
|
||||
public void PlatformRetailProfilesVerifyTheirActualFrontCoverGeometry(string platform, int width, int height) =>
|
||||
Assert.True(ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High, platform) >=
|
||||
ArtworkQuality.VerifiedThreshold);
|
||||
|
||||
[Theory]
|
||||
[InlineData("amiga-cd32", 1000, 1000)]
|
||||
[InlineData("fds", 900, 1200)]
|
||||
[InlineData("n64dd", 1290, 1120)]
|
||||
[InlineData("atari-lynx", 1300, 1600)]
|
||||
[InlineData("neo-geo-pocket", 1050, 1200)]
|
||||
[InlineData("wonderswan-color", 1000, 1500)]
|
||||
public void NicheRetailProfilesVerifyTheirDocumentedFrontGeometry(string platform, int width, int height) =>
|
||||
Assert.True(ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High, platform) >=
|
||||
ArtworkQuality.VerifiedThreshold);
|
||||
|
||||
[Theory]
|
||||
[InlineData("wiiu", "wii-u")]
|
||||
[InlineData("xbox360", "xbox-360")]
|
||||
[InlineData("xboxone", "xbox-one")]
|
||||
[InlineData("xboxseries", "xbox-series")]
|
||||
[InlineData("mastersystem", "master-system")]
|
||||
[InlineData("gamegear", "game-gear")]
|
||||
[InlineData("sega32x", "sega-32x")]
|
||||
[InlineData("segacd", "sega-cd")]
|
||||
public void HistoricalScanIdsUseTheirCanonicalRetailProfile(string historical, string canonical) =>
|
||||
Assert.Equal(ArtworkQuality.ExpectedRatio(canonical), ArtworkQuality.ExpectedRatio(historical));
|
||||
|
||||
[Fact]
|
||||
public void GenericPortraitIsRejectedAsPs5RetailCover() =>
|
||||
Assert.Equal(0, ArtworkQuality.Score(600, 900, null, "Provider", Confidence.High, "ps5"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("nds", 1220, 1350)]
|
||||
[InlineData("3ds", 1160, 1300)]
|
||||
[InlineData("n64", 1000, 1400)]
|
||||
[InlineData("snes", 1000, 1400)]
|
||||
public void InvertedOrWrongRegionGeometryIsRejected(string platform, int width, int height) =>
|
||||
Assert.Equal(0, ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High, platform));
|
||||
|
||||
[Fact]
|
||||
public void SteamLibraryArtCannotClaimToBeRetailPackaging() =>
|
||||
Assert.Equal(0, ArtworkQuality.Score(1000, 1400, null, "Steam", Confidence.High, "windows"));
|
||||
|
||||
[Fact]
|
||||
public void VerifiedDigitalOnlyFrontUsesItsStorefrontGeometryAndPosterRole()
|
||||
{
|
||||
var artwork = new GameArtwork(Guid.NewGuid(), Guid.NewGuid(), ArtworkRole.BoxFront,
|
||||
ArtworkVerificationStatus.NeedsReview, "SteamDigital", "SteamDigital", "2659050",
|
||||
"verified-digital-only-storefront-front", Confidence.High, 600, 900, 0.6667m, 100,
|
||||
new string('a', 64), 0, DateTimeOffset.UtcNow, false, "windows");
|
||||
|
||||
var evaluated = ArtworkQuality.EvaluateForPlatform(artwork, "windows");
|
||||
|
||||
Assert.True(evaluated.QualityScore >= ArtworkQuality.VerifiedThreshold);
|
||||
Assert.Equal(ArtworkRole.Poster, evaluated.Role);
|
||||
Assert.Equal(ArtworkVerificationStatus.AutomaticallyVerified, evaluated.VerificationStatus);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(512, 357, 1.4342)]
|
||||
[InlineData(400, 400, 1.0)]
|
||||
[InlineData(120, 180, 0.6667)]
|
||||
public void LandscapeSquareAndTinyImagesNeedReview(int width, int height, double ratio) =>
|
||||
Assert.True(ArtworkQuality.Score(width, height, (decimal)ratio, "Provider", Confidence.High) < ArtworkQuality.VerifiedThreshold);
|
||||
|
||||
[Fact]
|
||||
public void GeneratedArtworkCanNeverClaimVerifiedQuality() =>
|
||||
Assert.True(ArtworkQuality.Score(1200, 1800, 0.6667m, "Generated", Confidence.Deterministic) < ArtworkQuality.VerifiedThreshold);
|
||||
|
||||
[Fact]
|
||||
public void ManualCoverOverrideSurvivesPlatformEvaluation()
|
||||
{
|
||||
var uploaded = new GameArtwork(Guid.NewGuid(), Guid.NewGuid(), ArtworkRole.UserUpload,
|
||||
ArtworkVerificationStatus.ManuallyVerified, "Uploaded", "Uploaded", null, "operator-upload",
|
||||
Confidence.Deterministic, 400, 400, 1m, 10, new string('a', 64), 35,
|
||||
DateTimeOffset.UtcNow, Platform: null);
|
||||
|
||||
Assert.Equal(uploaded, ArtworkQuality.EvaluateForPlatform(uploaded, "ps5"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BestCandidateUsesQualityThenConfidenceAndNeverSelectsRejectedArtwork()
|
||||
{
|
||||
var gameId = Guid.NewGuid();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
GameArtwork Candidate(decimal score, Confidence confidence, ArtworkVerificationStatus status = ArtworkVerificationStatus.NeedsReview) =>
|
||||
new(Guid.NewGuid(), gameId, ArtworkRole.BoxFront, status, "fixture", "fixture", null, "fixture", confidence,
|
||||
600, 900, 0.6667m, 100, new string('a', 64), score, now, false);
|
||||
var low = Candidate(60, Confidence.Deterministic);
|
||||
var high = Candidate(90, Confidence.High);
|
||||
var rejected = Candidate(100, Confidence.Deterministic, ArtworkVerificationStatus.Rejected);
|
||||
|
||||
Assert.Equal(high.Id, ArtworkQuality.SelectBest([low, rejected, high])?.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text;
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class BrowserGameDataPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void VerifiedBindingMatchesOnlyTheExactArtifactRevisionAndCore()
|
||||
{
|
||||
var gameId = Guid.NewGuid();
|
||||
var artifactId = Guid.NewGuid();
|
||||
var sha256 = new string('a', 64);
|
||||
var artifact = Artifact(artifactId, version: 4, sha256: sha256);
|
||||
var session = Session(gameId, artifactId, "fceumm");
|
||||
var sourceId = BrowserGameDataPolicy.EncodeBinding(artifactId, artifact.Version, session.Core, sha256);
|
||||
|
||||
Assert.True(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId, session, artifact));
|
||||
Assert.False(BrowserGameDataPolicy.Matches("ManualUpload", sourceId, session, artifact));
|
||||
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, "not-base64", session, artifact));
|
||||
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId,
|
||||
session with { Core = "mgba" }, artifact));
|
||||
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId, session,
|
||||
artifact with { Version = artifact.Version + 1 }));
|
||||
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId, session,
|
||||
artifact with { Sha256 = new string('b', 64) }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreScopeIsFixtureVerifiedAndStableAcrossCoreCasing()
|
||||
{
|
||||
var gameId = Guid.NewGuid();
|
||||
|
||||
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("FCEUMM"));
|
||||
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("mgba"));
|
||||
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("gambatte"));
|
||||
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("snes9x"));
|
||||
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("melonds"));
|
||||
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("ppsspp"));
|
||||
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("pcsx_rearmed"));
|
||||
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("n64wasm"));
|
||||
Assert.False(BrowserGameDataPolicy.SupportsAutomaticRestore("mupen64plus_next"));
|
||||
Assert.Equal(BrowserGameDataPolicy.StableEntryId(gameId, "fceumm", GameDataKind.Save),
|
||||
BrowserGameDataPolicy.StableEntryId(gameId, "FCEUMM", GameDataKind.Save));
|
||||
Assert.NotEqual(BrowserGameDataPolicy.StableEntryId(gameId, "fceumm", GameDataKind.Save),
|
||||
BrowserGameDataPolicy.StableEntryId(gameId, "fceumm", GameDataKind.SaveState));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
BrowserGameDataPolicy.EncodeBinding(Guid.NewGuid(), 1, "mupen64plus_next", null));
|
||||
}
|
||||
|
||||
private static Artifact Artifact(Guid id, long version, string sha256) =>
|
||||
new(id, Guid.NewGuid(), "roms/nes/synthetic.nes", 16, DateTimeOffset.UnixEpoch,
|
||||
ArtifactState.Present, MediaType.Rom, Confidence.High, null, DateTimeOffset.UnixEpoch,
|
||||
DateTimeOffset.UnixEpoch, "ines", "nes", version, sha256);
|
||||
|
||||
private static BrowserPlaySession Session(Guid gameId, Guid artifactId, string core) =>
|
||||
new(Guid.NewGuid(), gameId, artifactId, PlaySessionState.Ready, "nes", "Ludarium EmulatorJS",
|
||||
core, "/player.html", Convert.ToHexString(Encoding.UTF8.GetBytes("token")),
|
||||
DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddHours(1));
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class BrowserPlayPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeterministicNesFixtureIsAllowlistedOnlyWhenSourceIsReadOnly()
|
||||
{
|
||||
var fixture = Convert.FromBase64String(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "fixtures", "minimal-ines.base64")).Trim());
|
||||
Assert.Equal([0x4e, 0x45, 0x53, 0x1a], fixture[..4]);
|
||||
var candidate = Candidate("nes", "roms/nes/synthetic.nes", fixture.Length, readOnly: true);
|
||||
|
||||
var result = BrowserPlayPolicy.Evaluate(candidate, configured: true, emulatorHealthy: true, DateTimeOffset.UnixEpoch);
|
||||
|
||||
Assert.True(result.Available);
|
||||
Assert.Equal(BrowserPlayState.Available, result.State);
|
||||
Assert.Equal("fceumm", result.Core);
|
||||
Assert.True(result.AutomaticRestore);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pc", "PC/setup.exe", BrowserPlayState.UnsupportedPlatform)]
|
||||
[InlineData("nes", "roms/nes/setup.exe", BrowserPlayState.UnsupportedFormat)]
|
||||
[InlineData("nes", "roms/nes/launch.bat", BrowserPlayState.UnsupportedFormat)]
|
||||
[InlineData("nes", "roms/nes/launch.ps1", BrowserPlayState.UnsupportedFormat)]
|
||||
[InlineData("nes", "roms/nes/archive.zip", BrowserPlayState.UnsupportedFormat)]
|
||||
[InlineData("nes", "roms/nes/../../private/game.nes", BrowserPlayState.UnsupportedFormat)]
|
||||
[InlineData("nes", "C:\\private\\game.nes", BrowserPlayState.UnsupportedFormat)]
|
||||
[InlineData("nes", "/private/game.nes", BrowserPlayState.UnsupportedFormat)]
|
||||
public void NativeExecutableScriptAndNonAllowlistedContentFailClosed(string platform, string path, BrowserPlayState state)
|
||||
{
|
||||
var result = BrowserPlayPolicy.Evaluate(Candidate(platform, path, 64, true), true, true, DateTimeOffset.UnixEpoch);
|
||||
Assert.False(result.Available);
|
||||
Assert.Equal(state, result.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritableMissingAndOversizedSourcesFailClosed()
|
||||
{
|
||||
Assert.Equal(BrowserPlayState.Disabled, BrowserPlayPolicy.Evaluate(Candidate("nes", "game.nes", 16, false), true, true, DateTimeOffset.UnixEpoch).State);
|
||||
Assert.Equal("fceumm", BrowserPlayPolicy.Evaluate(Candidate("nes", "game.nes", 16, true), false, false, DateTimeOffset.UnixEpoch).Core);
|
||||
Assert.Equal(BrowserPlayState.MissingRom, BrowserPlayPolicy.Evaluate(null, true, true, DateTimeOffset.UnixEpoch).State);
|
||||
Assert.Equal(BrowserPlayState.UnsupportedFormat, BrowserPlayPolicy.Evaluate(Candidate("nes", "game.nes", BrowserPlayPolicy.MaximumRomBytes + 1, true), true, true, DateTimeOffset.UnixEpoch).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeNameCannotExposeAStoredPath()
|
||||
{
|
||||
Assert.Equal("game.nes", BrowserPlayPolicy.SafeFileName("roms/nes/../../private/game.nes"));
|
||||
Assert.Equal("game.nes", BrowserPlayPolicy.SafeFileName("roms\\nes\\game.nes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixtureQualifiedCoreClaimsVerifiedAutomaticRestore()
|
||||
{
|
||||
var result = BrowserPlayPolicy.Evaluate(Candidate("gba", "roms/gba/synthetic.gba", 64, true),
|
||||
true, true, DateTimeOffset.UnixEpoch);
|
||||
|
||||
Assert.True(result.Available);
|
||||
Assert.Equal(BrowserPlayState.Available, result.State);
|
||||
Assert.Equal("mgba", result.Core);
|
||||
Assert.True(result.AutomaticRestore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void N64ZipContainerProceedsOnlyToTheServiceLevelContentValidation()
|
||||
{
|
||||
var result = BrowserPlayPolicy.Evaluate(Candidate("n64", "roms/n64/game.zip", 1024, true),
|
||||
true, true, DateTimeOffset.UnixEpoch);
|
||||
|
||||
Assert.True(result.Available);
|
||||
Assert.Equal("n64wasm", result.Core);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ps2UsesItsFixtureProvenDiscLimitWithoutClaimingStateRestore()
|
||||
{
|
||||
var belowLimit = BrowserPlayPolicy.Evaluate(
|
||||
Candidate("ps2", "roms/ps2/personal.iso", BrowserPlayPolicy.MaximumRomBytes + 1, true),
|
||||
true, true, DateTimeOffset.UnixEpoch);
|
||||
var aboveLimit = BrowserPlayPolicy.Evaluate(
|
||||
Candidate("ps2", "roms/ps2/personal.iso", 8L * 1024 * 1024 * 1024 + 1, true),
|
||||
true, true, DateTimeOffset.UnixEpoch);
|
||||
|
||||
Assert.True(belowLimit.Available);
|
||||
Assert.Equal("playjs", belowLimit.Core);
|
||||
Assert.Equal("Ludarium Play!.js", belowLimit.Emulator);
|
||||
Assert.False(belowLimit.AutomaticRestore);
|
||||
Assert.Equal(BrowserPlayState.UnsupportedFormat, aboveLimit.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryAdvertisedCoreHasAReleaseFixture()
|
||||
{
|
||||
var qualified = BrowserPlayPolicy.DescribePlatforms().Where(platform => platform.ReleaseQualified).ToArray();
|
||||
|
||||
Assert.Equal(13, qualified.Length);
|
||||
Assert.Contains(qualified, platform => platform.Platform == "nes" && platform.Core == "fceumm");
|
||||
Assert.Contains(qualified, platform => platform.Platform == "snes" && platform.Core == "snes9x");
|
||||
Assert.Contains(qualified, platform => platform.Platform == "gb" && platform.Core == "gambatte");
|
||||
Assert.Contains(qualified, platform => platform.Platform == "gbc" && platform.Core == "gambatte");
|
||||
Assert.Contains(qualified, platform => platform.Platform == "gba" && platform.Core == "mgba");
|
||||
Assert.Contains(qualified, platform => platform.Platform == "nds" && platform.Core == "melonds");
|
||||
Assert.Contains(qualified, platform => platform.Platform == "psp" && platform.Core == "ppsspp");
|
||||
Assert.Contains(qualified, platform => platform.Platform == "psx" && platform.Core == "pcsx_rearmed");
|
||||
Assert.Contains(qualified, platform => platform.Platform == "ps2" && platform.Core == "playjs" &&
|
||||
platform.MaximumBytes == 8L * 1024 * 1024 * 1024);
|
||||
Assert.Contains(qualified, platform => platform.Platform == "n64" && platform.Core == "n64wasm" &&
|
||||
platform.MaximumBytes == BrowserPlayPolicy.MaximumRomBytes);
|
||||
foreach (var platform in new[] { "genesis", "mastersystem", "gamegear" })
|
||||
Assert.Contains(qualified, entry => entry.Platform == platform && entry.Core == "genesis_plus_gx");
|
||||
}
|
||||
|
||||
private static BrowserPlayCandidate Candidate(string platform, string path, long size, bool readOnly) =>
|
||||
new(Guid.NewGuid(), Guid.NewGuid(), "Synthetic", platform, path, size, null, readOnly);
|
||||
[Fact]
|
||||
public void ACueSheetWinsOverTheTracksItDescribes()
|
||||
{
|
||||
var gameId = Guid.NewGuid();
|
||||
var sheet = Candidate(gameId, "roms/psx/Game/Game.cue", 512);
|
||||
var track = Candidate(gameId, "roms/psx/Game/Game (Track 1).bin", 700_000_000);
|
||||
var audio = Candidate(gameId, "roms/psx/Game/Game (Track 2).bin", 40_000_000);
|
||||
|
||||
// Without this the tracks make every multi-track disc look ambiguous and unplayable.
|
||||
Assert.Same(sheet, BrowserPlayPolicy.SelectCandidate([track, sheet, audio]));
|
||||
Assert.Same(track, BrowserPlayPolicy.SelectCandidate([track]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APlaylistWinsOverTheDiscsAndTracksItNames()
|
||||
{
|
||||
var gameId = Guid.NewGuid();
|
||||
var playlist = Candidate(gameId, "roms/psx/Game/Game.m3u", 64);
|
||||
var firstDisc = Candidate(gameId, "roms/psx/Game/Game (Disc 1).cue", 512);
|
||||
var secondDisc = Candidate(gameId, "roms/psx/Game/Game (Disc 2).cue", 512);
|
||||
var track = Candidate(gameId, "roms/psx/Game/Game (Disc 1) (Track 1).bin", 700_000_000);
|
||||
|
||||
// Descriptors outrank what they describe, most general first.
|
||||
Assert.Same(playlist, BrowserPlayPolicy.SelectCandidate([firstDisc, track, playlist, secondDisc]));
|
||||
Assert.Null(BrowserPlayPolicy.SelectCandidate([firstDisc, secondDisc, track]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMultiDiscGameIsDeliverableButNotYetQualified()
|
||||
{
|
||||
var psx = BrowserPlayPolicy.GetPlatform("psx")!;
|
||||
Assert.True(psx.Accepts(".m3u"));
|
||||
Assert.False(psx.IsQualified(".m3u"));
|
||||
Assert.False(BrowserPlayPolicy.GetPlatform("nes")!.Accepts(".m3u"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoSheetsOrTwoDiscsStayUnresolvable()
|
||||
{
|
||||
var gameId = Guid.NewGuid();
|
||||
Assert.Null(BrowserPlayPolicy.SelectCandidate([
|
||||
Candidate(gameId, "roms/psx/Game/Disc 1.cue", 512),
|
||||
Candidate(gameId, "roms/psx/Game/Disc 2.cue", 512)]));
|
||||
Assert.Null(BrowserPlayPolicy.SelectCandidate([
|
||||
Candidate(gameId, "roms/psx/Game/Disc 1.chd", 500_000_000),
|
||||
Candidate(gameId, "roms/psx/Game/Disc 2.chd", 500_000_000)]));
|
||||
Assert.Null(BrowserPlayPolicy.SelectCandidate([]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SegaPlatformsAreReleaseQualifiedByTheirDeterministicFixtures()
|
||||
{
|
||||
foreach (var platform in new[] { "genesis", "mastersystem", "gamegear" })
|
||||
{
|
||||
var mapping = BrowserPlayPolicy.GetPlatform(platform);
|
||||
Assert.NotNull(mapping);
|
||||
Assert.Equal("genesis_plus_gx", mapping.Core);
|
||||
// Rule 10: each of these completed the live core gate on its own pinned fixture, which
|
||||
// booted genesis_plus_gx, captured a savestate and restored it into a relaunched session.
|
||||
Assert.True(mapping.ReleaseQualified);
|
||||
Assert.Contains("genesis_plus_gx", BrowserPlayPolicy.RequiredCores);
|
||||
}
|
||||
|
||||
// The savestate half of that evidence is what lets a session restore without being asked.
|
||||
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("genesis_plus_gx"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryMappedCoreIsRequiredExceptLudariumsOwnPlayers()
|
||||
{
|
||||
Assert.DoesNotContain("playjs", BrowserPlayPolicy.RequiredCores);
|
||||
Assert.DoesNotContain("n64wasm", BrowserPlayPolicy.RequiredCores);
|
||||
foreach (var platform in BrowserPlayPolicy.DescribePlatforms())
|
||||
if (platform.Core is not "playjs" and not "n64wasm")
|
||||
Assert.Contains(platform.Core, BrowserPlayPolicy.RequiredCores);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACueSetIsDeliverableForPlayStationButNotForPlayJs()
|
||||
{
|
||||
var psx = BrowserPlayPolicy.GetPlatform("psx")!;
|
||||
var ps2 = BrowserPlayPolicy.GetPlatform("ps2")!;
|
||||
|
||||
// EmulatorJS mounts the streamed archive; Play!.js boots a raw disc image and cannot.
|
||||
Assert.True(psx.Accepts(".cue"));
|
||||
Assert.False(ps2.Accepts(".cue"));
|
||||
Assert.False(BrowserPlayPolicy.GetPlatform("nes")!.Accepts(".cue"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADeliverableButUnqualifiedFormatFailsClosedWithAnExactReason()
|
||||
{
|
||||
var gameId = Guid.NewGuid();
|
||||
var qualified = BrowserPlayPolicy.Evaluate(Candidate(gameId, "roms/psx/Game/Game.chd", 500_000_000),
|
||||
true, true, DateTimeOffset.UnixEpoch);
|
||||
var candidate = BrowserPlayPolicy.Evaluate(Candidate(gameId, "roms/psx/Game/Game.cue", 512),
|
||||
true, true, DateTimeOffset.UnixEpoch);
|
||||
var unknown = BrowserPlayPolicy.Evaluate(Candidate(gameId, "roms/psx/Game/Game.rar", 512),
|
||||
true, true, DateTimeOffset.UnixEpoch);
|
||||
|
||||
Assert.True(qualified.Available);
|
||||
// Rule 10: an implemented delivery path is not a support claim until a fixture proves it.
|
||||
Assert.False(candidate.Available);
|
||||
Assert.Equal(BrowserPlayState.UnsupportedFormat, candidate.State);
|
||||
Assert.Contains("not release-qualified", candidate.Message, StringComparison.Ordinal);
|
||||
Assert.False(unknown.Available);
|
||||
Assert.Contains("not allowlisted", unknown.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyTheFixtureProvenPlayStation2ContainerIsClaimedPlayable()
|
||||
{
|
||||
var ps2 = BrowserPlayPolicy.GetPlatform("ps2")!;
|
||||
|
||||
// Play!.js range-reads a raw block device and boots it as an ISO. Only the ISO fixture has
|
||||
// completed the live gate, so a compressed container is deliverable, never claimed.
|
||||
Assert.True(ps2.IsQualified(".iso"));
|
||||
foreach (var candidate in new[] { ".cso", ".chd", ".isz", ".bin" })
|
||||
{
|
||||
Assert.True(ps2.Accepts(candidate), candidate);
|
||||
Assert.False(ps2.IsQualified(candidate), candidate);
|
||||
Assert.False(BrowserPlayPolicy.Evaluate(
|
||||
Candidate(Guid.NewGuid(), $"roms/ps2/Game/Game{candidate}", 4_000_000_000),
|
||||
true, true, DateTimeOffset.UnixEpoch).Available, candidate);
|
||||
}
|
||||
Assert.True(BrowserPlayPolicy.Evaluate(Candidate(Guid.NewGuid(), "roms/ps2/Game/Game.iso", 4_000_000_000),
|
||||
true, true, DateTimeOffset.UnixEpoch).Available);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("snes", ".sfc", ".smc")]
|
||||
[InlineData("psx", ".chd", ".pbp")]
|
||||
[InlineData("psp", ".pbp", ".iso")]
|
||||
[InlineData("psp", ".pbp", ".cso")]
|
||||
[InlineData("genesis", ".md", ".gen")]
|
||||
[InlineData("genesis", ".md", ".smd")]
|
||||
[InlineData("genesis", ".md", ".bin")]
|
||||
public void ContainerClaimsStayBoundToTheExactLiveFixture(string platform, string qualifiedExtension,
|
||||
string candidateExtension)
|
||||
{
|
||||
var mapping = BrowserPlayPolicy.GetPlatform(platform)!;
|
||||
|
||||
Assert.True(mapping.IsQualified(qualifiedExtension));
|
||||
Assert.True(mapping.Accepts(candidateExtension));
|
||||
Assert.False(mapping.IsQualified(candidateExtension));
|
||||
var result = BrowserPlayPolicy.Evaluate(
|
||||
Candidate(Guid.NewGuid(), $"roms/{platform}/Fixture/Game{candidateExtension}", 64 * 1024),
|
||||
true, true, DateTimeOffset.UnixEpoch);
|
||||
Assert.False(result.Available);
|
||||
Assert.Equal(BrowserPlayState.UnsupportedFormat, result.State);
|
||||
Assert.Contains("not release-qualified", result.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static BrowserPlayCandidate Candidate(Guid gameId, string path, long size) =>
|
||||
new(gameId, Guid.NewGuid(), "Fixture", path.Split('/')[1], path, size, null, true);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class CatalogImportTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LogiqxImportIsVersionedStreamingAndDeterministic()
|
||||
{
|
||||
const string xml = """
|
||||
<?xml version="1.0"?>
|
||||
<datafile><header><name>Synthetic</name></header><game name="Example Game">
|
||||
<rom name="example.nes" size="16" crc="CBF43926" sha1="a9993e364706816aba3e25717850c26c9cd0d89d" sha256="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" />
|
||||
<rom name="ignored.bin" size="4" />
|
||||
</game></datafile>
|
||||
""";
|
||||
var store = new CapturingCatalogStore();
|
||||
var importer = new LogiqxCatalogImporter(store);
|
||||
await using var input = new MemoryStream(Encoding.UTF8.GetBytes(xml));
|
||||
var source = await importer.ImportLogiqxAsync("Synthetic DAT", "2026.07", input, CancellationToken.None);
|
||||
Assert.Equal("Ready", source.State); Assert.Equal(1, source.EntryCount); Assert.Equal(64, source.Sha256?.Length);
|
||||
var entry = Assert.Single(store.Entries);
|
||||
Assert.Equal("Example Game", entry.GameName); Assert.Equal("cbf43926", entry.Crc32);
|
||||
Assert.Equal(1, store.MatchCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogiqxImportRejectsDocumentTypes()
|
||||
{
|
||||
const string xml = "<!DOCTYPE datafile [<!ENTITY x SYSTEM 'file:///etc/passwd'>]><datafile><game name='x'><rom name='x' crc='00000000'/></game></datafile>";
|
||||
var importer = new LogiqxCatalogImporter(new CapturingCatalogStore());
|
||||
await using var input = new MemoryStream(Encoding.UTF8.GetBytes(xml));
|
||||
await Assert.ThrowsAsync<XmlException>(() => importer.ImportLogiqxAsync("Hostile", "1", input, CancellationToken.None));
|
||||
}
|
||||
|
||||
private sealed class CapturingCatalogStore : ICatalogStore
|
||||
{
|
||||
public List<CatalogEntry> Entries { get; } = [];
|
||||
public int MatchCalls { get; private set; }
|
||||
public Task UpsertCatalogSourceAsync(CatalogSource source, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task UpsertCatalogEntriesAsync(IReadOnlyList<CatalogEntry> entries, CancellationToken cancellationToken) { Entries.AddRange(entries); return Task.CompletedTask; }
|
||||
public Task<IReadOnlyList<CatalogSource>> ListCatalogSourcesAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<CatalogSource>>([]);
|
||||
public Task<int> MatchCatalogAsync(Guid sourceId, CancellationToken cancellationToken) { MatchCalls++; return Task.FromResult(Entries.Count); }
|
||||
public Task<IReadOnlyList<MatchCandidate>> ListMatchCandidatesAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<MatchCandidate>>([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Ludarium.Application;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class CatalogVisibilityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Steam")]
|
||||
[InlineData("FMT-v23.2")]
|
||||
[InlineData("Frosty Mod Tool 1.0")]
|
||||
[InlineData("Game DLC Unlocker")]
|
||||
public void SupportTitlesAreExcluded(string title) => Assert.False(CatalogVisibility.IsVisibleTitle(title));
|
||||
|
||||
[Theory]
|
||||
[InlineData("SteamWorld Dig")]
|
||||
[InlineData("Frostpunk")]
|
||||
[InlineData("A regular game")]
|
||||
public void RealGamesRemainVisible(string title) => Assert.True(CatalogVisibility.IsVisibleTitle(title));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class ClaimPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void LockedManualClaimOverridesAutomaticEvidenceAndSupersessionIsAppendOnly()
|
||||
{
|
||||
var entity = Guid.NewGuid(); var now = DateTimeOffset.UtcNow;
|
||||
var automatic = Claim(Guid.NewGuid(), entity, "Automatic title", Confidence.Deterministic, false, now);
|
||||
var manual = Claim(Guid.NewGuid(), entity, "Operator title", Confidence.High, true, now.AddSeconds(1));
|
||||
var corrected = Claim(Guid.NewGuid(), entity, "Corrected title", Confidence.Deterministic, true, now.AddSeconds(2), manual.Id);
|
||||
Assert.Equal("Operator title", ClaimPolicy.SelectEffective([automatic, manual])?.Value);
|
||||
Assert.Equal("Corrected title", ClaimPolicy.SelectEffective([automatic, manual, corrected])?.Value);
|
||||
Assert.Equal(3, new[] { automatic, manual, corrected }.Length);
|
||||
}
|
||||
|
||||
private static MetadataClaim Claim(Guid id, Guid entity, string value, Confidence confidence, bool locked, DateTimeOffset at, Guid? supersedes = null) =>
|
||||
new(id, "Game", entity, "title", value, locked ? "Manual" : "Analyzer", "test", confidence, [], locked, at, supersedes);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Ludarium.Application;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class DashboardSummaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConfidenceIsUnknownWithoutPresentArtifacts()
|
||||
{
|
||||
Assert.Null(Summary(0, 0, 0).ConfidencePercent);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(100, 100, 100, 100)]
|
||||
[InlineData(100, 50, 50, 50)]
|
||||
[InlineData(4, 3, 2, 62.5)]
|
||||
public void ConfidenceUsesOnlyMeasuredEvidence(long present, long hashed, long recognized, decimal expected)
|
||||
{
|
||||
Assert.Equal(expected, Summary(present, hashed, recognized).ConfidencePercent);
|
||||
}
|
||||
|
||||
private static DashboardSummary Summary(long present, long hashed, long recognized) =>
|
||||
new(0, present, present, 0, 0, 0, 0, hashed, recognized, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Ludarium.Application;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class DolphinLaunchPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void UniqueReadOnlyFixtureIsReleaseEligible()
|
||||
{
|
||||
var gameId = Guid.NewGuid();
|
||||
var candidate = Candidate("roms/wii/Synthetic.wbfs", true);
|
||||
|
||||
var capability = DolphinLaunchPolicy.EvaluateCapability(gameId, "wii", true, [candidate],
|
||||
DateTimeOffset.UnixEpoch, vaultBacked: true);
|
||||
|
||||
Assert.True(capability.Available);
|
||||
Assert.True(capability.AutomaticRestore);
|
||||
Assert.Equal("Dolphin", capability.Emulator);
|
||||
Assert.Equal("dolphin", capability.Player);
|
||||
Assert.Equal("wii", capability.Platform);
|
||||
Assert.True(capability.SupportsSavestates);
|
||||
|
||||
// Automatic restore is a live runtime property, never an assumption about the sidecar.
|
||||
Assert.False(DolphinLaunchPolicy.EvaluateCapability(gameId, "wii", true, [candidate],
|
||||
DateTimeOffset.UnixEpoch).AutomaticRestore);
|
||||
Assert.Equal("wii/Synthetic.wbfs", DolphinLaunchPolicy.ToRuntimePath("wii", candidate.RelativePath, "roms/wii"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmbiguousWritableAndUnsafeSourcesFailClosed()
|
||||
{
|
||||
var first = Candidate("roms/gamecube/One.rvz", true);
|
||||
var second = Candidate("roms/gamecube/Two.iso", true);
|
||||
Assert.Null(DolphinLaunchPolicy.SelectGame([first, second]));
|
||||
Assert.Null(DolphinLaunchPolicy.SelectGame([first with { SourceReadOnly = false }]));
|
||||
Assert.Null(DolphinLaunchPolicy.SelectGame([first with { RelativePath = "../One.rvz" }]));
|
||||
Assert.False(DolphinLaunchPolicy.EvaluateCapability(Guid.NewGuid(), "gamecube", true,
|
||||
[first, second]).Available);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ps2", true, NativeRemotePlayState.UnsupportedPlatform)]
|
||||
[InlineData("wii", false, NativeRemotePlayState.RuntimeUnavailable)]
|
||||
public void UnsupportedPlatformAndMissingRuntimeStayUnavailable(string platform, bool runtime,
|
||||
NativeRemotePlayState state)
|
||||
{
|
||||
var capability = DolphinLaunchPolicy.EvaluateCapability(Guid.NewGuid(), platform, runtime, []);
|
||||
Assert.Equal(state, capability.State);
|
||||
}
|
||||
|
||||
private static NativeLaunchCandidate Candidate(string path, bool readOnly) =>
|
||||
new(Guid.NewGuid(), path, 2 * 1024 * 1024, readOnly);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class FirmwarePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void PersonalFirmwareSlotsAreExplicitAndRuntimeClaimsStayHonest()
|
||||
{
|
||||
var requirements = FirmwarePolicy.Describe();
|
||||
|
||||
Assert.Contains(requirements, item => item.Platform == "psx" && item.Slot == "bios" && item.BrowserDelivery);
|
||||
Assert.Contains(requirements, item => item.Platform == "ps2" && item.Kind == FirmwareAssetKind.Bios && !item.BrowserDelivery);
|
||||
Assert.Equal(2, requirements.Count(item => item.Platform == "switch" && item.Kind == FirmwareAssetKind.KeySet && !item.BrowserDelivery));
|
||||
Assert.All(requirements, item => Assert.Contains("runtime", item.RuntimeState, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("psx", "bios", "scph.bin")]
|
||||
[InlineData("ps2", "bios", "personal.rom")]
|
||||
[InlineData("switch", "prod-keys", "prod.keys")]
|
||||
public void AllowlistedNamesAreAccepted(string platform, string slot, string fileName) =>
|
||||
Assert.Equal(fileName, FirmwarePolicy.ValidateFileName(FirmwarePolicy.Get(platform, slot), fileName));
|
||||
|
||||
[Theory]
|
||||
[InlineData("psx", "bios", "setup.exe")]
|
||||
[InlineData("switch", "prod-keys", "keys.zip")]
|
||||
[InlineData("switch", "firmware", "firmware.bin")]
|
||||
public void UnknownSlotsAndUnsafeExtensionsFailClosed(string platform, string slot, string fileName) =>
|
||||
Assert.Throws<FirmwareValidationException>(() =>
|
||||
FirmwarePolicy.ValidateFileName(FirmwarePolicy.Get(platform, slot), fileName));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class GameDataVaultPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void MetadataIsTrimmedAndBounded()
|
||||
{
|
||||
var result = GameDataVaultPolicy.Normalize(new GameDataEntryInput(GameDataKind.Save,
|
||||
" Main slot ", " mGBA ", " Steam Deck ", " Before the final dungeon "));
|
||||
Assert.Equal("Main slot", result.Name);
|
||||
Assert.Equal("mGBA", result.Emulator);
|
||||
Assert.Equal("Steam Deck", result.Device);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../slot.sav")]
|
||||
[InlineData("folder\\slot.sav")]
|
||||
[InlineData("payload.exe")]
|
||||
[InlineData("script.ps1")]
|
||||
public void UnsafeFileNamesAreRejected(string fileName) =>
|
||||
Assert.Throws<ArgumentException>(() => GameDataVaultPolicy.ValidateFileName(fileName));
|
||||
|
||||
[Fact]
|
||||
public void NormalSaveFileNameIsPreserved() =>
|
||||
Assert.Equal("campaign-slot-1.srm", GameDataVaultPolicy.ValidateFileName("campaign-slot-1.srm"));
|
||||
|
||||
[Fact]
|
||||
public void UnknownKindIsRejected() =>
|
||||
Assert.Throws<ArgumentException>(() => GameDataVaultPolicy.Normalize(
|
||||
new GameDataEntryInput((GameDataKind)999, "Slot", null, null, null)));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// The library browser's question to the catalog.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Filtering and ordering are properties of the query, not of the page the browser happens to hold.
|
||||
/// The playable-copy filter in particular must stay derived from the players' own policies, so it
|
||||
/// can never offer a format nothing accepts.
|
||||
/// </remarks>
|
||||
public sealed class GameQueryTests
|
||||
{
|
||||
[Fact]
|
||||
public void AnEmptyQueryAsksForTheWholeCatalog()
|
||||
{
|
||||
var query = new GameQuery();
|
||||
|
||||
Assert.False(query.HasFilters);
|
||||
Assert.Equal(1, query.Page);
|
||||
Assert.Equal(50, query.PageSize);
|
||||
Assert.Null(query.Sort);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("title")]
|
||||
[InlineData("recent")]
|
||||
[InlineData("updated")]
|
||||
[InlineData("played")]
|
||||
[InlineData("rating")]
|
||||
public void EverySortKeyTheInterfaceOffersIsPartOfTheContract(string sort) =>
|
||||
Assert.Contains(sort, GameQuery.SortKeys);
|
||||
|
||||
[Fact]
|
||||
public void EveryLibraryFilterCountsAsAFilter()
|
||||
{
|
||||
Assert.True(new GameQuery { Search = "mario" }.HasFilters);
|
||||
Assert.True(new GameQuery { Platform = "snes" }.HasFilters);
|
||||
Assert.True(new GameQuery { Favorite = true }.HasFilters);
|
||||
Assert.True(new GameQuery { Status = GamePlayStatus.Playing }.HasFilters);
|
||||
Assert.True(new GameQuery { CollectionId = Guid.NewGuid() }.HasFilters);
|
||||
Assert.True(new GameQuery { Tag = "co-op" }.HasFilters);
|
||||
Assert.True(new GameQuery { MinimumRating = 4 }.HasFilters);
|
||||
Assert.True(new GameQuery { PlayableOnly = true }.HasFilters);
|
||||
// Sorting and paging change the order, not the population.
|
||||
Assert.False(new GameQuery { Sort = "rating", Page = 3 }.HasFilters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayableFormatsCoverEveryFormatAPlayerAccepts()
|
||||
{
|
||||
foreach (var platform in BrowserPlayPolicy.DescribePlatforms())
|
||||
foreach (var extension in platform.Extensions)
|
||||
Assert.Contains($"{platform.Platform}|{extension}".ToLowerInvariant(), PlayableFormats.Pairs);
|
||||
|
||||
foreach (var player in NativeRemotePlayerRegistry.All)
|
||||
foreach (var owned in player.Platforms)
|
||||
foreach (var extension in player.Extensions)
|
||||
Assert.Contains($"{owned}|{extension}".ToLowerInvariant(), PlayableFormats.Pairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayableFormatsNeverOfferAPairNoPlayerAccepts()
|
||||
{
|
||||
Assert.DoesNotContain("nes|.iso", PlayableFormats.Pairs);
|
||||
Assert.DoesNotContain("wii|.nes", PlayableFormats.Pairs);
|
||||
// Play!.js cannot mount an archive, so a PS2 CUE set is not a playable copy.
|
||||
Assert.DoesNotContain("ps2|.cue", PlayableFormats.Pairs);
|
||||
Assert.Contains("psx|.cue", PlayableFormats.Pairs);
|
||||
Assert.Contains("gamecube|.rvz", PlayableFormats.Pairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayableFormatsAreOrderedLowerCasedPairs()
|
||||
{
|
||||
Assert.All(PlayableFormats.Pairs, pair =>
|
||||
{
|
||||
Assert.Equal(pair.ToLowerInvariant(), pair);
|
||||
var parts = pair.Split('|');
|
||||
Assert.Equal(2, parts.Length);
|
||||
Assert.StartsWith(".", parts[1], StringComparison.Ordinal);
|
||||
});
|
||||
Assert.Equal(PlayableFormats.Pairs.Order(StringComparer.Ordinal), PlayableFormats.Pairs);
|
||||
Assert.Equal(PlayableFormats.Pairs.Distinct(), PlayableFormats.Pairs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class HealthRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void ChangedContentIsCriticalAndUnknownContentIsNotice()
|
||||
{
|
||||
var unknown = new Artifact(Guid.NewGuid(), Guid.NewGuid(), "mystery.bin", 1, DateTimeOffset.UtcNow, ArtifactState.Present, MediaType.Unknown, Confidence.None, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null);
|
||||
Assert.Equal(Severity.Notice, HealthRules.EvaluateArtifact(unknown)?.Severity);
|
||||
var root = LibraryRoot.Create("Unsafe", Path.GetTempPath(), LibraryKind.Mixed) with { IsAvailable = true, IsReadOnly = false };
|
||||
Assert.Equal(Severity.Warning, HealthRules.EvaluateRoot(root)?.Severity);
|
||||
var bundle = new Bundle(Guid.NewGuid(), BundleKind.CueBin, "Disc", BundleState.Incomplete, [], ["missing"]);
|
||||
Assert.Equal(Severity.Warning, HealthRules.EvaluateBundle(bundle)?.Severity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class LibraryExperiencePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void SmartCollectionRulesNormalizeTagsAndPlatform()
|
||||
{
|
||||
var input = LibraryExperiencePolicy.Normalize(new GameCollectionInput(" Favorites ", " curated ",
|
||||
CollectionKind.Smart, new CollectionRule(Platform: "SNES", Tags: [" Co-Op ", "co-op"]), true));
|
||||
Assert.Equal("Favorites", input.Name);
|
||||
Assert.Equal("snes", input.Rule?.Platform);
|
||||
Assert.Equal(["co-op"], Assert.IsType<string[]>(input.Rule?.Tags));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PersonalStateRejectsRatingsOutsideDocumentedRange() =>
|
||||
Assert.Throws<ArgumentException>(() => LibraryExperiencePolicy.Normalize(new GameUserStateInput(false,
|
||||
GamePlayStatus.Backlog, 11, null, null, null, 0, null)));
|
||||
|
||||
[Fact]
|
||||
public void ExternalMediaRejectsInsecureUrls() =>
|
||||
Assert.Throws<ArgumentException>(() => LibraryExperiencePolicy.Normalize(new GameMediaInput(
|
||||
GameMediaKind.Screenshot, "Screenshot", "http://example.test/image.png")));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Ludarium.Domain\Ludarium.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Ludarium.Application\Ludarium.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\fixtures\synthetic\browserplay\minimal-ines.base64" Link="fixtures\minimal-ines.base64" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="..\..\fixtures\synthetic\browserplay\psp\ludarium-psp-fixture.pbp.gz" Link="fixtures\ludarium-psp-fixture.pbp.gz" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Ludarium.Application;
|
||||
using OpenMcdf;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class MsiDatabaseAnalysisTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadsProductMediaAndFileRelationshipsWithoutInstallerApis()
|
||||
{
|
||||
var path = CreateFixture(embeddedCabinet: true, includeCabinetStream: true);
|
||||
try
|
||||
{
|
||||
var result = MsiDatabaseAnalysis.Inspect(path);
|
||||
|
||||
Assert.Equal("Ludarium Fixture", result.Properties["ProductName"]);
|
||||
Assert.Equal("2.4.1", result.Properties["ProductVersion"]);
|
||||
Assert.Equal("1033", result.Properties["ProductLanguage"]);
|
||||
Assert.Equal("Intel;1033", result.Properties["Template"]);
|
||||
var cabinet = Assert.Single(result.Cabinets);
|
||||
Assert.True(cabinet.Embedded);
|
||||
Assert.True(cabinet.StreamPresent);
|
||||
Assert.Equal("media1.cab", cabinet.Cabinet);
|
||||
var file = Assert.Single(result.Files);
|
||||
Assert.Equal("game.exe", file.FileName);
|
||||
Assert.Equal(4096, file.Size);
|
||||
Assert.Equal(1, file.Sequence);
|
||||
Assert.True(result.IsComplete);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsMissingEmbeddedCabinetWithoutExtractingIt()
|
||||
{
|
||||
var path = CreateFixture(embeddedCabinet: true, includeCabinetStream: false);
|
||||
try
|
||||
{
|
||||
var result = MsiDatabaseAnalysis.Inspect(path);
|
||||
Assert.False(result.IsComplete);
|
||||
Assert.Contains(result.Findings, finding => finding.Contains("missing", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistinguishesExternalCabinetRelationships()
|
||||
{
|
||||
var path = CreateFixture(embeddedCabinet: false, includeCabinetStream: false);
|
||||
try
|
||||
{
|
||||
var cabinet = Assert.Single(MsiDatabaseAnalysis.Inspect(path).Cabinets);
|
||||
Assert.False(cabinet.Embedded);
|
||||
Assert.False(cabinet.StreamPresent);
|
||||
Assert.Equal("media1.cab", cabinet.Cabinet);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsTruncatedAndOversizedMetadataStreams()
|
||||
{
|
||||
var truncated = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
|
||||
var oversized = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
|
||||
try
|
||||
{
|
||||
using (var root = RootStorage.Create(truncated))
|
||||
using (var stream = root.CreateStream(Encode("_StringPool"))) stream.Write([0xE4, 0x04, 0, 0, 1]);
|
||||
Assert.ThrowsAny<Exception>(() => MsiDatabaseAnalysis.Inspect(truncated));
|
||||
|
||||
using (var root = RootStorage.Create(oversized))
|
||||
using (var stream = root.CreateStream(Encode("_StringPool"))) stream.Write(new byte[8 * 1024 * 1024 + 1]);
|
||||
Assert.Throws<InvalidDataException>(() => MsiDatabaseAnalysis.Inspect(oversized));
|
||||
}
|
||||
finally { File.Delete(truncated); File.Delete(oversized); }
|
||||
}
|
||||
|
||||
private static string CreateFixture(bool embeddedCabinet, bool includeCabinetStream)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
|
||||
var columns = new Dictionary<string, (string Name, ushort Type)[]>(StringComparer.Ordinal)
|
||||
{
|
||||
["Property"] = [("Property", 0x0d48), ("Value", 0x1dff)],
|
||||
["Media"] = [("DiskId", 0x2502), ("LastSequence", 0x0502), ("DiskPrompt", 0x1dff),
|
||||
("Cabinet", 0x1dff), ("VolumeLabel", 0x1d20), ("Source", 0x1d48)],
|
||||
["File"] = [("File", 0x2d48), ("Component_", 0x0d48), ("FileName", 0x0dff),
|
||||
("FileSize", 0x0104), ("Version", 0x1d48), ("Language", 0x1d14),
|
||||
("Attributes", 0x0502), ("Sequence", 0x0502)]
|
||||
};
|
||||
var propertyRows = new[]
|
||||
{
|
||||
new[] { "ProductName", "Ludarium Fixture" }, new[] { "ProductVersion", "2.4.1" },
|
||||
new[] { "ProductLanguage", "1033" }, new[] { "Manufacturer", "Ludarium" },
|
||||
new[] { "ProductCode", "{11111111-2222-3333-4444-555555555555}" }, new[] { "Template", "Intel;1033" }
|
||||
};
|
||||
var mediaRows = new[] { new[] { "1", "1", "Install media", embeddedCabinet ? "#media1.cab" : "media1.cab", "LUDARIUM", "" } };
|
||||
var fileRows = new[] { new[] { "GameExe", "GameComponent", "game.exe", "4096", "2.4.1", "1033", "0", "1" } };
|
||||
|
||||
var strings = columns.SelectMany(table => new[] { table.Key }.Concat(table.Value.Select(column => column.Name)))
|
||||
.Concat(propertyRows.SelectMany(row => row)).Concat(mediaRows.SelectMany(row => row))
|
||||
.Concat(fileRows.SelectMany(row => row)).Where(value => value.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal).ToList();
|
||||
var ids = strings.Select((value, index) => (value, id: (ushort)(index + 1))).ToDictionary(item => item.value, item => item.id, StringComparer.Ordinal);
|
||||
|
||||
using (var root = RootStorage.Create(path))
|
||||
{
|
||||
var data = strings.SelectMany(value => Encoding.Latin1.GetBytes(value)).ToArray();
|
||||
var pool = new byte[4 + strings.Count * 4];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pool, 1252);
|
||||
for (var index = 0; index < strings.Count; index++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pool.AsSpan(4 + index * 4, 2), (ushort)Encoding.Latin1.GetByteCount(strings[index]));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pool.AsSpan(6 + index * 4, 2), 1);
|
||||
}
|
||||
Write(root, "_StringPool", pool); Write(root, "_StringData", data);
|
||||
|
||||
var columnRows = columns.SelectMany(table => table.Value.Select((column, index) => new[]
|
||||
{ ids[table.Key], (ushort)(index + 1), ids[column.Name], column.Type })).ToArray();
|
||||
Write(root, "_Columns", WriteU16Columns(columnRows));
|
||||
Write(root, "Property", WriteTable(columns["Property"], propertyRows, ids));
|
||||
Write(root, "Media", WriteTable(columns["Media"], mediaRows, ids));
|
||||
Write(root, "File", WriteTable(columns["File"], fileRows, ids));
|
||||
if (includeCabinetStream) Write(root, "media1.cab", "MSCF"u8.ToArray());
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private static byte[] WriteTable((string Name, ushort Type)[] columns, string[][] rows, Dictionary<string, ushort> ids)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
foreach (var (column, columnIndex) in columns.Select((value, index) => (value, index)))
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var value = row[columnIndex];
|
||||
var integer = (column.Type & 0x0f00) < 0x0800;
|
||||
var width = integer && (column.Type & 0x0fff) == 0x0104 ? 4 : 2;
|
||||
var raw = integer ? (long.Parse(value, System.Globalization.CultureInfo.InvariantCulture) + (width == 4 ? 0x80000000L : 0x8000L)) : value.Length == 0 ? 0 : ids[value];
|
||||
Span<byte> bytes = new byte[4];
|
||||
if (width == 4) BinaryPrimitives.WriteUInt32LittleEndian(bytes, (uint)raw);
|
||||
else BinaryPrimitives.WriteUInt16LittleEndian(bytes, (ushort)raw);
|
||||
output.Write(bytes[..width]);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] WriteU16Columns(ushort[][] rows)
|
||||
{
|
||||
var bytes = new byte[rows.Length * rows[0].Length * 2];
|
||||
for (var column = 0; column < rows[0].Length; column++)
|
||||
for (var row = 0; row < rows.Length; row++)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan((column * rows.Length + row) * 2, 2), rows[row][column]);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void Write(RootStorage root, string name, byte[] bytes)
|
||||
{
|
||||
using var stream = root.CreateStream(Encode(name));
|
||||
stream.Write(bytes);
|
||||
}
|
||||
private static string Encode(string name)
|
||||
{
|
||||
const string alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._!";
|
||||
var output = new StringBuilder();
|
||||
for (var index = 0; index < name.Length; index += 2)
|
||||
{
|
||||
var first = alphabet.IndexOf(name[index]);
|
||||
var second = index + 1 < name.Length ? alphabet.IndexOf(name[index + 1]) : -1;
|
||||
if (first >= 0 && second >= 0) output.Append((char)(0x3800 + first + (second << 6)));
|
||||
else if (first >= 0) output.Append((char)(0x4800 + first));
|
||||
else { output.Append(name[index]); if (index + 1 < name.Length) { output.Append(name[index + 1]); index++; } }
|
||||
}
|
||||
return output.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Platform identification for the container formats the players actually accept.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A disc or cartridge that classifies as Unknown never becomes a launch candidate, so a format the
|
||||
/// Dolphin policy or the browser matrix allows must also be identifiable here. These fixtures are
|
||||
/// synthesized from the documented on-media headers, not from copyrighted dumps.
|
||||
/// </remarks>
|
||||
public sealed class PlatformIdentificationTests
|
||||
{
|
||||
[Fact]
|
||||
public void GameCubeDiscsAreIdentifiedByTheirMagicWordNotTheirDirectory()
|
||||
{
|
||||
var classification = ArtifactAnalysis.Classify("library/unsorted/Disc.iso", GameCubeDisc());
|
||||
|
||||
Assert.Equal(MediaType.DiscImage, classification.MediaType);
|
||||
Assert.Equal("gamecube", classification.Platform);
|
||||
Assert.Equal(Confidence.Deterministic, classification.Confidence);
|
||||
Assert.Contains(classification.Evidence, item => item.Kind == "signature" && item.Value == "GameCube disc");
|
||||
Assert.Contains(classification.Evidence, item => item.Kind == "rom.gameCode" && item.Value == "GLZE");
|
||||
Assert.Contains(classification.Evidence, item => item.Kind == "rom.region" && item.Value == "usa");
|
||||
Assert.Contains(classification.Evidence, item => item.Kind == "rom.title" && item.Value == "LUDARIUM GAMECUBE FIXTURE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WiiDiscsAreIdentifiedByTheirMagicWordNotTheirDirectory()
|
||||
{
|
||||
var classification = ArtifactAnalysis.Classify("library/unsorted/Disc.iso", WiiDisc());
|
||||
|
||||
Assert.Equal(MediaType.DiscImage, classification.MediaType);
|
||||
Assert.Equal("wii", classification.Platform);
|
||||
Assert.Equal(Confidence.Deterministic, classification.Confidence);
|
||||
Assert.Contains(classification.Evidence, item => item.Kind == "signature" && item.Value == "Wii disc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADiscMagicOverridesAMisleadingPlatformDirectory()
|
||||
{
|
||||
var classification = ArtifactAnalysis.Classify("roms/gamecube/Disc.iso", WiiDisc(), "gamecube");
|
||||
|
||||
Assert.Equal("wii", classification.Platform);
|
||||
Assert.Contains(classification.Evidence, item => item.Kind == "platform.conflict" &&
|
||||
item.Value == "directory:gamecube;signature:wii");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("roms/gamecube/Game.gcm", "gamecube")]
|
||||
[InlineData("roms/gamecube/Game.gcz", "gamecube")]
|
||||
[InlineData("roms/wii/Game.wia", "wii")]
|
||||
[InlineData("roms/wii/Game.rvz", "wii")]
|
||||
[InlineData("roms/wii/Game.wbfs", "wii")]
|
||||
public void EveryDolphinContainerFormatClassifiesAsAPlayableDiscImage(string path, string hint)
|
||||
{
|
||||
var classification = ArtifactAnalysis.Classify(path, [], hint);
|
||||
|
||||
Assert.Equal(MediaType.DiscImage, classification.MediaType);
|
||||
Assert.Equal(hint, classification.Platform);
|
||||
Assert.True(classification.Supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryFormatTheDolphinPolicyAcceptsIsIdentifiable()
|
||||
{
|
||||
foreach (var extension in NativeRemotePlayerRegistry.Dolphin.Extensions)
|
||||
{
|
||||
var classification = ArtifactAnalysis.Classify($"roms/wii/Game{extension}", [], "wii");
|
||||
Assert.True(classification.Supported,
|
||||
$"{extension} is launchable in Dolphin but is not identified by the scanner.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MegaDriveCartridgesAreIdentifiedAndSeparatedFromTheirVariants()
|
||||
{
|
||||
var genesis = ArtifactAnalysis.Classify("roms/unsorted/Game.md", SegaCartridge("SEGA MEGA DRIVE "));
|
||||
var thirtyTwoX = ArtifactAnalysis.Classify("roms/unsorted/Game.32x", SegaCartridge("SEGA 32X "));
|
||||
var segaCd = ArtifactAnalysis.Classify("roms/unsorted/Game.bin", SegaCartridge("SEGA SEGACD "));
|
||||
|
||||
Assert.Equal("genesis", genesis.Platform);
|
||||
Assert.Equal(MediaType.Rom, genesis.MediaType);
|
||||
Assert.Equal("sega32x", thirtyTwoX.Platform);
|
||||
Assert.Equal("segacd", segaCd.Platform);
|
||||
Assert.Contains(genesis.Evidence, item => item.Kind == "rom.internationalTitle" &&
|
||||
item.Value == "LUDARIUM GENESIS FIXTURE");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x40, "mastersystem")]
|
||||
[InlineData(0x30, "mastersystem")]
|
||||
[InlineData(0x50, "gamegear")]
|
||||
[InlineData(0x60, "gamegear")]
|
||||
[InlineData(0x70, "gamegear")]
|
||||
public void TheEightBitSegaRegionNibbleSeparatesMasterSystemFromGameGear(int region, string expected)
|
||||
{
|
||||
var classification = ArtifactAnalysis.Classify("roms/unsorted/Game.sms", SegaEightBit(region));
|
||||
|
||||
Assert.Equal(expected, classification.Platform);
|
||||
Assert.Equal(MediaType.Rom, classification.MediaType);
|
||||
Assert.Equal(Confidence.Deterministic, classification.Confidence);
|
||||
Assert.Contains(classification.Evidence, item => item.Kind == "rom.productCode" && item.Value == "50123");
|
||||
Assert.Contains(classification.Evidence, item => item.Kind == "rom.revision" && item.Value == "1");
|
||||
Assert.Contains(classification.Evidence, item => item.Kind == "rom.checksum" && item.Value == "1234");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("megadrive", "genesis")]
|
||||
[InlineData("mega-drive", "genesis")]
|
||||
[InlineData("MD", "genesis")]
|
||||
[InlineData("sms", "mastersystem")]
|
||||
[InlineData("game-gear", "gamegear")]
|
||||
public void HardwareNamedDirectoriesResolveToTheCanonicalPlatform(string directory, string expected)
|
||||
{
|
||||
var hint = ArtifactAnalysis.InferPlatformHint("library", "/mnt/games", $"roms/{directory}/Game.md");
|
||||
Assert.Equal(expected, hint);
|
||||
Assert.Equal(expected, ArtifactAnalysis.Classify($"roms/{directory}/Game.md", [], hint).Platform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformValuesWrittenByEarlierVersionsStillResolve()
|
||||
{
|
||||
Assert.Equal("genesis", ArtifactAnalysis.CanonicalPlatform("sega-genesis"));
|
||||
Assert.Equal("mastersystem", ArtifactAnalysis.CanonicalPlatform("sms"));
|
||||
Assert.Equal("nes", ArtifactAnalysis.CanonicalPlatform("nes"));
|
||||
Assert.Null(ArtifactAnalysis.CanonicalPlatform(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARawTrackIsOnlyADiscImageInsideADiscLibrary()
|
||||
{
|
||||
var disc = ArtifactAnalysis.Classify("roms/psx/Game/Game (Track 1).bin", [], "psx");
|
||||
var installer = ArtifactAnalysis.Classify("PC/Some Game/data1.bin", [], "windows");
|
||||
|
||||
Assert.Equal(MediaType.DiscImage, disc.MediaType);
|
||||
Assert.Equal("psx", disc.Platform);
|
||||
// An installer payload must not be promoted to a disc image on the strength of its extension.
|
||||
Assert.Equal(MediaType.Unknown, installer.MediaType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("roms/3ds/Game.3ds", "3ds")]
|
||||
[InlineData("roms/3ds/Game.cia", "3ds")]
|
||||
[InlineData("roms/wiiu/Game.wud", "wiiu")]
|
||||
[InlineData("roms/psvita/Game.vpk", "psvita")]
|
||||
public void FormatsForResearchedPlatformsAreIdentifiedEvenWithoutAPlayer(string path, string platform)
|
||||
{
|
||||
var classification = ArtifactAnalysis.Classify(path, []);
|
||||
|
||||
Assert.Equal(platform, classification.Platform);
|
||||
Assert.True(classification.Supported);
|
||||
}
|
||||
|
||||
private static byte[] GameCubeDisc()
|
||||
{
|
||||
var header = new byte[0x500];
|
||||
Encoding.ASCII.GetBytes("GLZE").CopyTo(header, 0);
|
||||
Encoding.ASCII.GetBytes("01").CopyTo(header, 4);
|
||||
header[6] = 0;
|
||||
header[7] = 1;
|
||||
BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(0x1C), 0xC2339F3D);
|
||||
Encoding.ASCII.GetBytes("LUDARIUM GAMECUBE FIXTURE").CopyTo(header, 0x20);
|
||||
return header;
|
||||
}
|
||||
|
||||
private static byte[] WiiDisc()
|
||||
{
|
||||
var header = new byte[0x500];
|
||||
Encoding.ASCII.GetBytes("RLZP").CopyTo(header, 0);
|
||||
Encoding.ASCII.GetBytes("01").CopyTo(header, 4);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(0x18), 0x5D1C9EA3);
|
||||
Encoding.ASCII.GetBytes("LUDARIUM WII FIXTURE").CopyTo(header, 0x20);
|
||||
return header;
|
||||
}
|
||||
|
||||
private static byte[] SegaCartridge(string console)
|
||||
{
|
||||
var rom = new byte[0x200];
|
||||
Encoding.ASCII.GetBytes(console.PadRight(16)[..16]).CopyTo(rom, 0x100);
|
||||
Encoding.ASCII.GetBytes("(C)LUDARIUM 2026".PadRight(16)).CopyTo(rom, 0x110);
|
||||
Encoding.ASCII.GetBytes("LUDARIUM GENESIS FIXTURE".PadRight(48)).CopyTo(rom, 0x120);
|
||||
Encoding.ASCII.GetBytes("LUDARIUM GENESIS FIXTURE".PadRight(48)).CopyTo(rom, 0x150);
|
||||
Encoding.ASCII.GetBytes("GM 00000000-00".PadRight(14)).CopyTo(rom, 0x180);
|
||||
Encoding.ASCII.GetBytes("JUE").CopyTo(rom, 0x1F0);
|
||||
return rom;
|
||||
}
|
||||
|
||||
private static byte[] SegaEightBit(int region)
|
||||
{
|
||||
var rom = new byte[0x8000];
|
||||
Encoding.ASCII.GetBytes("TMR SEGA").CopyTo(rom, 0x7FF0);
|
||||
rom[0x7FFA] = 0x34; // checksum, low byte
|
||||
rom[0x7FFB] = 0x12; // checksum, high byte
|
||||
rom[0x7FFC] = 0x23; // product code, low BCD pair
|
||||
rom[0x7FFD] = 0x01; // product code, high BCD pair
|
||||
rom[0x7FFE] = 0x51; // top product digit and revision 1
|
||||
rom[0x7FFF] = (byte)(region | 0x0C); // region nibble plus a 32 KiB size nibble
|
||||
return rom;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Diagnostics;
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class ScaleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(10_000)]
|
||||
[InlineData(100_000)]
|
||||
[Trait("Category", "Scale")]
|
||||
public void ClassifierProcessesLargeSyntheticInventoriesWithinBoundedResources(int count)
|
||||
{
|
||||
var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var recognized = 0;
|
||||
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var platform = index % 2 == 0 ? "nes" : "gba";
|
||||
var extension = platform == "nes" ? "nes" : "gba";
|
||||
ReadOnlySpan<byte> header = platform == "nes"
|
||||
? new byte[] { 0x4e, 0x45, 0x53, 0x1a }
|
||||
: new byte[] { 0, 0, 0, 0, 0x24, 0xff, 0xae, 0x51 };
|
||||
var result = ArtifactAnalysis.Classify($"roms/{platform}/Synthetic-{index:D6}.{extension}",
|
||||
header, platform);
|
||||
if (result.MediaType == MediaType.Rom && result.Platform == platform) recognized++;
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;
|
||||
Assert.Equal(count, recognized);
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(30), $"Classification took {stopwatch.Elapsed}.");
|
||||
Assert.True(allocated < 512L * 1024 * 1024, $"Classification allocated {allocated:N0} bytes.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class SnapshotComparerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComparisonDistinguishesAddedRemovedMovedChangedAndUnchanged()
|
||||
{
|
||||
var root = Guid.NewGuid();
|
||||
var moved = Guid.NewGuid(); var changed = Guid.NewGuid(); var removed = Guid.NewGuid(); var unchanged = Guid.NewGuid(); var added = Guid.NewGuid();
|
||||
var before = Snapshot("before",
|
||||
new(root, "old/game.bin", 1, Hash('a'), moved, null),
|
||||
new(root, "changed.bin", 1, Hash('b'), changed, null),
|
||||
new(root, "removed.bin", 1, Hash('c'), removed, null),
|
||||
new(root, "same.bin", 1, Hash('d'), unchanged, null));
|
||||
var after = Snapshot("after",
|
||||
new(root, "new/game.bin", 1, Hash('a'), moved, null),
|
||||
new(root, "changed.bin", 2, Hash('e'), changed, null),
|
||||
new(root, "same.bin", 1, Hash('d'), unchanged, null),
|
||||
new(root, "added.bin", 1, Hash('f'), added, null));
|
||||
var result = new SnapshotComparer().Compare(before, after);
|
||||
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Added);
|
||||
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Removed);
|
||||
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Moved);
|
||||
Assert.Single(result, x => x.Kind == SnapshotChangeKind.ContentChanged);
|
||||
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Unchanged);
|
||||
}
|
||||
|
||||
private static IntegritySnapshot Snapshot(string name, params SnapshotItem[] items) => new(Guid.NewGuid(), name, "1", "test", DateTimeOffset.UtcNow, new ReadOnlyCollection<SnapshotItem>(items));
|
||||
private static string Hash(char value) => new(value, 64);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Text.Json;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class SnapshotSerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void PersistedSnapshotRoundTripsThroughJson()
|
||||
{
|
||||
var expected = new IntegritySnapshot(Guid.NewGuid(), "baseline", "6", "0.3.1", DateTimeOffset.UtcNow,
|
||||
[new SnapshotItem(Guid.NewGuid(), "roms/nes/example.nes", 16, new string('a', 64), Guid.NewGuid(), null)]);
|
||||
|
||||
var actual = JsonSerializer.Deserialize<IntegritySnapshot>(JsonSerializer.Serialize(expected));
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.Single(actual.Items);
|
||||
Assert.Equal(expected.Items[0].RelativePath, actual.Items[0].RelativePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Ludarium.Application;
|
||||
|
||||
namespace Ludarium.UnitTests;
|
||||
|
||||
public sealed class SwitchLaunchPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelectsUniqueReadOnlyBaseXciAndRejectsTicketUpdateAndDlc()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Candidate("Animal Crossing ticket [01006F800232712C][v0].nsp", 124_112),
|
||||
Candidate("Animal Crossing update [01006F8002326800][v1769472].nsp", 4_332_291_043),
|
||||
Candidate("Happy Home Paradise [01006F80023273E8][v0].nsp", 624_384_208),
|
||||
Candidate("Animal Crossing New Horizons [01006F8002326000][v0].xci", 7_985_954_816),
|
||||
};
|
||||
|
||||
Assert.EndsWith(".xci", SwitchLaunchPolicy.SelectBaseGame(candidates)!.RelativePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailsClosedWhenTheBaseGameIsAmbiguousOrWritable()
|
||||
{
|
||||
var first = Candidate("Game A [0100000000000000][v0].xci", 2_000_000);
|
||||
var second = Candidate("Game B [0100000000010000][v0].xci", 3_000_000);
|
||||
Assert.Null(SwitchLaunchPolicy.SelectBaseGame([first, second]));
|
||||
Assert.Null(SwitchLaunchPolicy.SelectBaseGame([first with { SourceReadOnly = false }]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsOnlyContainedCatalogPaths()
|
||||
{
|
||||
Assert.Equal("Animal/Game [0100000000000000][v0].xci",
|
||||
SwitchLaunchPolicy.ToRuntimePath("roms/switch/Animal/Game [0100000000000000][v0].xci", "roms/switch"));
|
||||
Assert.Throws<InvalidOperationException>(() => SwitchLaunchPolicy.ToRuntimePath("roms/ps5/game.xci", "roms/switch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapabilityIsAvailableOnlyForOneQualifiedBaseGame()
|
||||
{
|
||||
var gameId = Guid.NewGuid();
|
||||
var available = SwitchLaunchPolicy.EvaluateCapability(gameId, true, true, true,
|
||||
[Candidate("Game [0100000000000000][v0].xci", 2_000_000)]);
|
||||
var ambiguous = SwitchLaunchPolicy.EvaluateCapability(gameId, true, true, true,
|
||||
[Candidate("Game A [0100000000000000][v0].xci", 2_000_000), Candidate("Game B [0100000000010000][v0].xci", 3_000_000)]);
|
||||
|
||||
Assert.True(available.Available);
|
||||
Assert.Equal(NativeRemotePlayState.Available, available.State);
|
||||
Assert.False(ambiguous.Available);
|
||||
Assert.Equal(NativeRemotePlayState.AmbiguousMapping, ambiguous.State);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, true, true, NativeRemotePlayState.UnsupportedPlatform)]
|
||||
[InlineData(true, false, true, NativeRemotePlayState.RuntimeUnavailable)]
|
||||
[InlineData(true, true, false, NativeRemotePlayState.MissingKeys)]
|
||||
public void CapabilityExplainsGlobalPrerequisites(bool isSwitch, bool runtime, bool keys,
|
||||
NativeRemotePlayState expected)
|
||||
{
|
||||
var capability = SwitchLaunchPolicy.EvaluateCapability(Guid.NewGuid(), isSwitch, runtime, keys, []);
|
||||
Assert.False(capability.Available);
|
||||
Assert.Equal(expected, capability.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapabilityDistinguishesAnUpdateWithoutItsBaseGameFromAmbiguity()
|
||||
{
|
||||
var capability = SwitchLaunchPolicy.EvaluateCapability(Guid.NewGuid(), true, true, true,
|
||||
[Candidate("Game update [0100000000000800][v131072].nsp", 4_000_000)]);
|
||||
|
||||
Assert.False(capability.Available);
|
||||
Assert.Equal(NativeRemotePlayState.MissingBaseGame, capability.State);
|
||||
Assert.Contains("updates and DLC cannot start alone", capability.Message);
|
||||
}
|
||||
|
||||
private static NativeLaunchCandidate Candidate(string path, long size) => new(Guid.NewGuid(), "roms/switch/" + path, size, true);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.Test.Sdk": {
|
||||
"type": "Direct",
|
||||
"requested": "[18.8.1, )",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeCoverage": "18.8.1",
|
||||
"Microsoft.TestPlatform.TestHost": "18.8.1"
|
||||
}
|
||||
},
|
||||
"xunit": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.9.3, )",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.18.0",
|
||||
"xunit.assert": "2.9.3",
|
||||
"xunit.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.runner.visualstudio": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.1.5, )",
|
||||
"resolved": "3.1.5",
|
||||
"contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
|
||||
},
|
||||
"Microsoft.CodeCoverage": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q=="
|
||||
},
|
||||
"Microsoft.TestPlatform.ObjectModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw=="
|
||||
},
|
||||
"Microsoft.TestPlatform.TestHost": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==",
|
||||
"dependencies": {
|
||||
"Microsoft.TestPlatform.ObjectModel": "18.8.1"
|
||||
}
|
||||
},
|
||||
"OpenMcdf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.0",
|
||||
"contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg=="
|
||||
},
|
||||
"SharpCompress": {
|
||||
"type": "Transitive",
|
||||
"resolved": "0.50.4",
|
||||
"contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w=="
|
||||
},
|
||||
"System.IO.Hashing": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ=="
|
||||
},
|
||||
"xunit.abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.3",
|
||||
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.18.0",
|
||||
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
|
||||
},
|
||||
"xunit.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
|
||||
},
|
||||
"xunit.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]",
|
||||
"xunit.extensibility.execution": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
|
||||
"dependencies": {
|
||||
"xunit.abstractions": "2.0.3"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.execution": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"ludarium.application": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Ludarium.Domain": "[1.0.0, )",
|
||||
"OpenMcdf": "[3.2.0, )",
|
||||
"SharpCompress": "[0.50.4, )",
|
||||
"System.IO.Hashing": "[10.0.11, )"
|
||||
}
|
||||
},
|
||||
"ludarium.domain": {
|
||||
"type": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user