/*
+------------------------------------------------------------------+
| Author: Ivan Murzak (https://github.com/IvanMurzak) |
| Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) |
| Copyright (c) 2025 Ivan Murzak |
| Licensed under the Apache License, Version 2.0. |
| See the LICENSE file in the project root for more information. |
+------------------------------------------------------------------+
*/
#nullable enable
using System.IO;
using System.Linq;
using NUnit.Framework;
using com.IvanMurzak.Unity.MCP.Editor.DependencyResolver;
namespace com.IvanMurzak.Unity.MCP.Editor.Tests.DependencyResolverTests
{
///
/// Coverage for the on-disk manifest .nuget-installed.json introduced
/// for the flat layout (issue #733). The manifest is the primary source of
/// truth for "which DLL belongs to which package at which version", with
/// versioned-filename parsing as the disaster-recovery fallback.
///
[TestFixture]
public class NuGetInstallManifestTests
{
string _installPath = string.Empty;
[SetUp]
public void SetUp()
{
_installPath = Path.Combine(
Path.GetTempPath(),
"UnityMcp-Manifest-" + Path.GetRandomFileName());
Directory.CreateDirectory(_installPath);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_installPath))
{
try { Directory.Delete(_installPath, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
[Test]
public void Load_ReturnsEmptyManifest_WhenFileMissing()
{
var manifest = NuGetInstallManifest.Load(_installPath);
Assert.AreEqual(0, manifest.Packages.Count);
}
[Test]
public void SaveThenLoad_RoundTrips_AllFields()
{
var manifest = new InstallManifest();
var entry = new InstalledPackage("8.0.15");
entry.Dlls.Add("Microsoft.AspNetCore.SignalR.Client.8.0.15.dll");
entry.Dlls.Add("Microsoft.AspNetCore.SignalR.Client.Core.8.0.15.dll");
manifest.Packages["Microsoft.AspNetCore.SignalR.Client"] = entry;
// Multi-DLL package with a different version.
var multi = new InstalledPackage("10.0.3");
multi.Dlls.Add("System.Memory.10.0.3.dll");
multi.Dlls.Add("System.Buffers.10.0.3.dll");
manifest.Packages["Microsoft.Bcl.Memory"] = multi;
// Empty-DLL entry (development-only dependency).
manifest.Packages["Microsoft.CodeAnalysis.Analyzers"] = new InstalledPackage("3.11.0");
NuGetInstallManifest.Save(_installPath, manifest);
var roundTrip = NuGetInstallManifest.Load(_installPath);
Assert.AreEqual(3, roundTrip.Packages.Count);
Assert.AreEqual("8.0.15", roundTrip.Packages["Microsoft.AspNetCore.SignalR.Client"].Version);
Assert.AreEqual(2, roundTrip.Packages["Microsoft.AspNetCore.SignalR.Client"].Dlls.Count);
Assert.AreEqual("10.0.3", roundTrip.Packages["Microsoft.Bcl.Memory"].Version);
CollectionAssert.AreEquivalent(
new[] { "System.Memory.10.0.3.dll", "System.Buffers.10.0.3.dll" },
roundTrip.Packages["Microsoft.Bcl.Memory"].Dlls);
Assert.AreEqual("3.11.0", roundTrip.Packages["Microsoft.CodeAnalysis.Analyzers"].Version);
Assert.AreEqual(0, roundTrip.Packages["Microsoft.CodeAnalysis.Analyzers"].Dlls.Count);
}
[Test]
public void Save_CreatesInstallDirectoryIfMissing()
{
var nested = Path.Combine(_installPath, "nested-not-yet-created");
Assert.IsFalse(Directory.Exists(nested));
var manifest = new InstallManifest();
NuGetInstallManifest.Save(nested, manifest);
Assert.IsTrue(Directory.Exists(nested));
Assert.IsTrue(File.Exists(Path.Combine(nested, ".nuget-installed.json")));
}
[Test]
public void Load_ReturnsEmptyManifest_OnMalformedJson_AndDoesNotThrow()
{
File.WriteAllText(Path.Combine(_installPath, ".nuget-installed.json"), "{ not valid json");
var manifest = NuGetInstallManifest.Load(_installPath);
Assert.AreEqual(0, manifest.Packages.Count);
}
[Test]
public void Load_PreservesCaseInsensitiveLookup()
{
// The runtime resolver matches package IDs case-insensitively
// throughout. The manifest must round-trip with the same property.
var manifest = new InstallManifest();
manifest.Packages["System.Text.Json"] = new InstalledPackage("8.0.5");
NuGetInstallManifest.Save(_installPath, manifest);
var loaded = NuGetInstallManifest.Load(_installPath);
Assert.IsTrue(loaded.Packages.ContainsKey("system.text.json"),
"Package ID lookup must be case-insensitive after a round-trip.");
Assert.IsTrue(loaded.Packages.ContainsKey("SYSTEM.TEXT.JSON"));
}
[Test]
public void Save_IsIdempotent_RoundTripIsByteForByte()
{
// Two saves of the same logical manifest must produce the exact
// same bytes — keeps git diffs clean and avoids spurious
// post-restore changes.
var manifest = new InstallManifest();
var entry = new InstalledPackage("8.0.5");
entry.Dlls.Add("System.Text.Json.8.0.5.dll");
manifest.Packages["System.Text.Json"] = entry;
NuGetInstallManifest.Save(_installPath, manifest);
var first = File.ReadAllBytes(Path.Combine(_installPath, ".nuget-installed.json"));
// Round-trip and save again.
var loaded = NuGetInstallManifest.Load(_installPath);
NuGetInstallManifest.Save(_installPath, loaded);
var second = File.ReadAllBytes(Path.Combine(_installPath, ".nuget-installed.json"));
CollectionAssert.AreEqual(first, second);
}
[Test]
public void TryRebuildFromDisk_ReproducesSingleDllPackagesFromVersionedFilenames()
{
// Disaster recovery (#733 acceptance criterion): user deletes
// .nuget-installed.json. The next restore must rebuild the manifest
// from on-disk versioned filenames, with no re-extraction needed.
File.WriteAllText(Path.Combine(_installPath, "Microsoft.AspNetCore.Http.Connections.Client.8.0.15.dll"), "dummy");
File.WriteAllText(Path.Combine(_installPath, "System.Text.Json.8.0.5.dll"), "dummy");
File.WriteAllText(Path.Combine(_installPath, "R3.1.3.0.dll"), "dummy");
// Plus an unrelated file the rebuild must ignore.
File.WriteAllText(Path.Combine(_installPath, "ReadMe.txt"), "user notes");
var rebuilt = NuGetInstallManifest.TryRebuildFromDisk(_installPath);
Assert.AreEqual(3, rebuilt.Packages.Count);
Assert.AreEqual("8.0.15", rebuilt.Packages["Microsoft.AspNetCore.Http.Connections.Client"].Version);
Assert.AreEqual("8.0.5", rebuilt.Packages["System.Text.Json"].Version);
Assert.AreEqual("1.3.0", rebuilt.Packages["R3"].Version);
}
[Test]
public void TryRebuildFromDisk_IgnoresLegacyUnversionedDllsAndNonDllFiles()
{
// Pre-flat-layout artifacts the user might still have on disk —
// the parser must reject them so the rebuild stays consistent.
File.WriteAllText(Path.Combine(_installPath, "System.Memory.dll"), "legacy unversioned");
File.WriteAllText(Path.Combine(_installPath, "ReadMe.md"), "notes");
var rebuilt = NuGetInstallManifest.TryRebuildFromDisk(_installPath);
Assert.AreEqual(0, rebuilt.Packages.Count);
}
[Test]
public void TryRebuildFromDisk_KeysMultiDllPackagesUnderSyntheticStems()
{
// Microsoft.Bcl.Memory is the canonical multi-DLL package: it ships
// System.Memory, System.Buffers, System.Runtime.CompilerServices.Unsafe.
// The disaster-recovery rebuild has no way to recover the real owner
// from filenames alone, so it MUST key those DLLs under their own
// stems as synthetic IDs. The follow-up MigrateSyntheticOwnerEntries
// call (driven from NuGetPackageInstaller.Install) reconciles the
// synthetic IDs back onto the real package ID — see the
// ReconcilesSyntheticEntries_ForMultiDllPackage test below.
File.WriteAllText(Path.Combine(_installPath, "System.Memory.10.0.3.dll"), "dummy");
File.WriteAllText(Path.Combine(_installPath, "System.Buffers.10.0.3.dll"), "dummy");
File.WriteAllText(Path.Combine(_installPath, "System.Runtime.CompilerServices.Unsafe.10.0.3.dll"), "dummy");
var rebuilt = NuGetInstallManifest.TryRebuildFromDisk(_installPath);
Assert.AreEqual(3, rebuilt.Packages.Count);
Assert.IsTrue(rebuilt.Packages.ContainsKey("System.Memory"));
Assert.IsTrue(rebuilt.Packages.ContainsKey("System.Buffers"));
Assert.IsTrue(rebuilt.Packages.ContainsKey("System.Runtime.CompilerServices.Unsafe"));
Assert.IsFalse(rebuilt.Packages.ContainsKey("Microsoft.Bcl.Memory"),
"Filename-only rebuild cannot recover the real owning package id; the test guards the synthetic-id contract that MigrateSyntheticOwnerEntries depends on.");
}
[Test]
public void MigrateSyntheticOwnerEntries_ReconcilesMultiDllPackage_AndDoesNotTripCollisionCheck()
{
// End-to-end coverage of the post-rebuild reconciliation flow:
// 1. Seed a multi-DLL package's flat-layout DLLs without a manifest.
// 2. Run TryRebuildFromDisk — expect synthetic stem-keyed entries.
// 3. Run MigrateSyntheticOwnerEntries with the real package id and a
// planned-DLL list that matches the on-disk filenames.
// 4. Assert the synthetic entries are gone and the real package id
// now owns those same DLLs at the same version.
// Without this fix, NuGetPackageInstaller.Install would log
// "Refusing to install Microsoft.Bcl.Memory ..." and stick the user
// on the disaster-recovery path forever.
File.WriteAllText(Path.Combine(_installPath, "System.Memory.10.0.3.dll"), "dummy");
File.WriteAllText(Path.Combine(_installPath, "System.Buffers.10.0.3.dll"), "dummy");
File.WriteAllText(Path.Combine(_installPath, "System.Runtime.CompilerServices.Unsafe.10.0.3.dll"), "dummy");
var manifest = NuGetInstallManifest.TryRebuildFromDisk(_installPath);
// Sanity: the rebuild produced synthetic entries (the [high] reproduction state).
Assert.IsTrue(manifest.Packages.ContainsKey("System.Memory"));
Assert.IsFalse(manifest.Packages.ContainsKey("Microsoft.Bcl.Memory"));
var planned = new System.Collections.Generic.List
{
new PlannedDll("lib/net8.0/System.Memory.dll", "System.Memory.10.0.3.dll", Path.Combine(_installPath, "System.Memory.10.0.3.dll")),
new PlannedDll("lib/net8.0/System.Buffers.dll", "System.Buffers.10.0.3.dll", Path.Combine(_installPath, "System.Buffers.10.0.3.dll")),
new PlannedDll("lib/net8.0/System.Runtime.CompilerServices.Unsafe.dll", "System.Runtime.CompilerServices.Unsafe.10.0.3.dll", Path.Combine(_installPath, "System.Runtime.CompilerServices.Unsafe.10.0.3.dll")),
};
NuGetPackageInstaller.MigrateSyntheticOwnerEntries(manifest, "Microsoft.Bcl.Memory", "10.0.3", planned);
Assert.IsFalse(manifest.Packages.ContainsKey("System.Memory"),
"Synthetic stem-keyed entry must be removed after reconciliation.");
Assert.IsFalse(manifest.Packages.ContainsKey("System.Buffers"));
Assert.IsFalse(manifest.Packages.ContainsKey("System.Runtime.CompilerServices.Unsafe"));
Assert.IsTrue(manifest.Packages.ContainsKey("Microsoft.Bcl.Memory"),
"Real package id must own the migrated DLLs after reconciliation.");
Assert.AreEqual("10.0.3", manifest.Packages["Microsoft.Bcl.Memory"].Version);
CollectionAssert.AreEquivalent(
new[]
{
"System.Memory.10.0.3.dll",
"System.Buffers.10.0.3.dll",
"System.Runtime.CompilerServices.Unsafe.10.0.3.dll",
},
manifest.Packages["Microsoft.Bcl.Memory"].Dlls);
}
[Test]
public void MigrateSyntheticOwnerEntries_DoesNotTouchUnrelatedPackages()
{
// Defense: the migrator must only strip entries whose DLL set is a
// subset of the planned filenames. A real distinct package that
// happens to share a version number must survive untouched.
var manifest = new InstallManifest();
var unrelatedEntry = new InstalledPackage("10.0.3");
unrelatedEntry.Dlls.Add("Newtonsoft.Json.10.0.3.dll");
manifest.Packages["Newtonsoft.Json"] = unrelatedEntry;
var syntheticEntry = new InstalledPackage("10.0.3");
syntheticEntry.Dlls.Add("System.Memory.10.0.3.dll");
manifest.Packages["System.Memory"] = syntheticEntry;
var planned = new System.Collections.Generic.List
{
new PlannedDll("lib/net8.0/System.Memory.dll", "System.Memory.10.0.3.dll", "/ignored/System.Memory.10.0.3.dll"),
};
NuGetPackageInstaller.MigrateSyntheticOwnerEntries(manifest, "Microsoft.Bcl.Memory", "10.0.3", planned);
Assert.IsTrue(manifest.Packages.ContainsKey("Newtonsoft.Json"),
"Unrelated same-version package must not be migrated away.");
Assert.IsFalse(manifest.Packages.ContainsKey("System.Memory"),
"Synthetic same-version entry whose DLLs match the planned set must be migrated.");
Assert.IsTrue(manifest.Packages.ContainsKey("Microsoft.Bcl.Memory"));
}
[Test]
public void MigrateSyntheticOwnerEntries_PartialDiskState_LeavesPlannedDllsMissingFromManifestEntry()
{
// Regression for the alreadyOnDisk gate: after MigrateSyntheticOwnerEntries
// pulls only the synthetic entries that the disaster-recovery rebuild saw on
// disk (a strict subset of the package's planned DLLs), the resulting
// real-owner entry must NOT advertise the missing DLLs. NuGetPackageInstaller
// gates `alreadyOnDisk` on `planned ⊆ manifestEntry.Dlls`, so an incomplete
// migration here MUST yield an entry whose Dlls set fails that superset
// check — otherwise extraction would be skipped and the missing files would
// never be re-extracted.
//
// Scenario: Microsoft.Bcl.Memory ships 3 DLLs; only 2 survived on disk
// (manifest deleted + one DLL lost to AV / partial cleanup). The user
// re-runs at the same package version. The post-migration manifest entry
// covers only the 2 surviving stems; planned still has 3.
File.WriteAllText(Path.Combine(_installPath, "System.Memory.10.0.3.dll"), "dummy");
File.WriteAllText(Path.Combine(_installPath, "System.Buffers.10.0.3.dll"), "dummy");
// System.Runtime.CompilerServices.Unsafe.10.0.3.dll intentionally NOT seeded.
var manifest = NuGetInstallManifest.TryRebuildFromDisk(_installPath);
Assert.IsTrue(manifest.Packages.ContainsKey("System.Memory"));
Assert.IsTrue(manifest.Packages.ContainsKey("System.Buffers"));
Assert.IsFalse(manifest.Packages.ContainsKey("System.Runtime.CompilerServices.Unsafe"),
"Sanity: the missing DLL must not appear in the rebuilt manifest.");
var planned = new System.Collections.Generic.List
{
new PlannedDll("lib/net8.0/System.Memory.dll", "System.Memory.10.0.3.dll", Path.Combine(_installPath, "System.Memory.10.0.3.dll")),
new PlannedDll("lib/net8.0/System.Buffers.dll", "System.Buffers.10.0.3.dll", Path.Combine(_installPath, "System.Buffers.10.0.3.dll")),
new PlannedDll("lib/net8.0/System.Runtime.CompilerServices.Unsafe.dll", "System.Runtime.CompilerServices.Unsafe.10.0.3.dll", Path.Combine(_installPath, "System.Runtime.CompilerServices.Unsafe.10.0.3.dll")),
};
var migrated = NuGetPackageInstaller.MigrateSyntheticOwnerEntries(
manifest, "Microsoft.Bcl.Memory", "10.0.3", planned);
Assert.IsTrue(migrated, "Migration must signal that the manifest changed (caller must persist).");
Assert.IsTrue(manifest.Packages.ContainsKey("Microsoft.Bcl.Memory"));
var realEntry = manifest.Packages["Microsoft.Bcl.Memory"];
CollectionAssert.AreEquivalent(
new[] { "System.Memory.10.0.3.dll", "System.Buffers.10.0.3.dll" },
realEntry.Dlls,
"Migrated entry must reflect on-disk reality (2 of 3 DLLs), not the full planned set.");
// Cross-check the gate the installer uses: planned ⊄ realEntry.Dlls, so
// alreadyOnDisk would return false and the missing DLL gets re-extracted.
var allPlannedRecorded = planned.TrueForAll(
p => realEntry.Dlls.Contains(p.FileName, System.StringComparer.OrdinalIgnoreCase));
Assert.IsFalse(allPlannedRecorded,
"alreadyOnDisk gate would short-circuit incorrectly if the planned set were a subset of the migrated entry.");
}
[Test]
public void MigrateSyntheticOwnerEntries_ReturnsFalse_WhenNothingMigrated()
{
// Caller persists only on a true return — ensure we don't churn the manifest
// file when the migrator was a no-op.
var manifest = new InstallManifest();
var unrelatedEntry = new InstalledPackage("9.9.9");
unrelatedEntry.Dlls.Add("Unrelated.9.9.9.dll");
manifest.Packages["Unrelated"] = unrelatedEntry;
var planned = new System.Collections.Generic.List
{
new PlannedDll("lib/net8.0/System.Memory.dll", "System.Memory.10.0.3.dll", "/ignored/System.Memory.10.0.3.dll"),
};
var migrated = NuGetPackageInstaller.MigrateSyntheticOwnerEntries(
manifest, "Microsoft.Bcl.Memory", "10.0.3", planned);
Assert.IsFalse(migrated);
}
[Test]
public void MigrateSyntheticOwnerEntries_IgnoresEntriesAtDifferentVersion()
{
// A stem-keyed entry left at a DIFFERENT version is not part of the
// reconciliation scope; it gets cleaned up by the stale-version
// pass instead.
var manifest = new InstallManifest();
var olderSynthetic = new InstalledPackage("9.0.0");
olderSynthetic.Dlls.Add("System.Memory.9.0.0.dll");
manifest.Packages["System.Memory"] = olderSynthetic;
var planned = new System.Collections.Generic.List
{
new PlannedDll("lib/net8.0/System.Memory.dll", "System.Memory.10.0.3.dll", "/ignored/System.Memory.10.0.3.dll"),
};
NuGetPackageInstaller.MigrateSyntheticOwnerEntries(manifest, "Microsoft.Bcl.Memory", "10.0.3", planned);
Assert.IsTrue(manifest.Packages.ContainsKey("System.Memory"),
"Different-version synthetic entry must be left alone (stale-version cleanup handles it).");
Assert.IsFalse(manifest.Packages.ContainsKey("Microsoft.Bcl.Memory"),
"No real-owner entry should be created when nothing was migrated.");
}
[Test]
public void TryParseInstalledDllName_GreedilyConsumesEntireVersionTail()
{
// Regression check on the parser used by the disaster-recovery
// rebuild: for "System.Memory.10.0.3.dll" the version is "10.0.3"
// (not "0.3" or "3"), and the stem is "System.Memory".
Assert.IsTrue(NuGetInstallManifest.TryParseInstalledDllName(
"System.Memory.10.0.3.dll", out var stem, out var version));
Assert.AreEqual("System.Memory", stem);
Assert.AreEqual("10.0.3", version);
}
[Test]
public void TryParseInstalledDllName_HandlesPackageStemsWithDots()
{
Assert.IsTrue(NuGetInstallManifest.TryParseInstalledDllName(
"Microsoft.AspNetCore.Http.Connections.Client.8.0.15.dll", out var stem, out var version));
Assert.AreEqual("Microsoft.AspNetCore.Http.Connections.Client", stem);
Assert.AreEqual("8.0.15", version);
}
[Test]
public void TryParseInstalledDllName_HandlesShortStemAndLongVersion()
{
// "R3.1.3.0.dll" → stem "R3", version "1.3.0".
Assert.IsTrue(NuGetInstallManifest.TryParseInstalledDllName(
"R3.1.3.0.dll", out var stem, out var version));
Assert.AreEqual("R3", stem);
Assert.AreEqual("1.3.0", version);
}
[Test]
public void TryParseInstalledDllName_RejectsLegacyUnversionedFilename()
{
// No version tail → not a flat-layout install entry.
Assert.IsFalse(NuGetInstallManifest.TryParseInstalledDllName(
"System.Memory.dll", out _, out _));
}
[Test]
public void TryParseInstalledDllName_RejectsMalformedTail()
{
// ".bar" tail isn't a System.Version.
Assert.IsFalse(NuGetInstallManifest.TryParseInstalledDllName(
"Foo.bar.dll", out _, out _));
}
[Test]
public void TryParseInstalledDllName_RejectsNonDllExtension()
{
Assert.IsFalse(NuGetInstallManifest.TryParseInstalledDllName(
"System.Memory.10.0.3.exe", out _, out _));
}
}
}