using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Scripting;
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
///
/// Pins the per-compile native-metadata fix (2026-08-12 follow-up to 5a781c70):
/// Roslyn's default ScriptMetadataResolver re-resolves the transitive assembly
/// closure through MetadataReference.CreateFromFile on EVERY script compile —
/// each call minting a fresh AssemblyMetadata → PEReader → NativeHeapMemoryBlock.
/// The decorator memoizes so each distinct assembly is resolved once per process.
///
public class CachingScriptMetadataResolverTests
{
private sealed class CountingResolver : MetadataReferenceResolver
{
public int MissingCalls;
public int ReferenceCalls;
public override bool ResolveMissingAssemblies => true;
public override PortableExecutableReference? ResolveMissingAssembly(
MetadataReference definition, AssemblyIdentity referenceIdentity)
{
Interlocked.Increment(ref MissingCalls);
return MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
}
public override ImmutableArray ResolveReference(
string reference, string? baseFilePath, MetadataReferenceProperties properties)
{
Interlocked.Increment(ref ReferenceCalls);
return [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)];
}
public override bool Equals(object? other) => ReferenceEquals(this, other);
public override int GetHashCode() => 0;
}
private static readonly AssemblyIdentity SomeIdentity = new("System.Fake", new Version(1, 0, 0, 0));
private static readonly MetadataReference SomeDefinition =
MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
[Fact]
public void ResolveMissingAssembly_SameIdentityTwice_ResolvesOnceAndSharesInstance()
{
var inner = new CountingResolver();
var sut = new CachingScriptMetadataResolver(inner);
var first = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
var second = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
Assert.Equal(1, inner.MissingCalls);
Assert.Same(first, second); // one AssemblyMetadata/PEReader/native block, not two
}
[Fact]
public void ResolveMissingAssembly_DistinctIdentities_ResolveIndependently()
{
var inner = new CountingResolver();
var sut = new CachingScriptMetadataResolver(inner);
sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
sut.ResolveMissingAssembly(SomeDefinition, new AssemblyIdentity("System.Other", new Version(1, 0, 0, 0)));
Assert.Equal(2, inner.MissingCalls);
}
[Fact]
public void ResolveReference_SameArgsTwice_ResolvesOnceAndSharesInstances()
{
var inner = new CountingResolver();
var sut = new CachingScriptMetadataResolver(inner);
var first = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly);
var second = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly);
Assert.Equal(1, inner.ReferenceCalls);
Assert.Same(first[0], second[0]);
}
[Fact]
public void Instance_IsProcessWideSingleton()
=> Assert.Same(CachingScriptMetadataResolver.Instance, CachingScriptMetadataResolver.Instance);
[Fact]
public void ResolveMissingAssemblies_DelegatesToInner()
{
var sut = new CachingScriptMetadataResolver(new CountingResolver());
Assert.True(sut.ResolveMissingAssemblies);
}
///
/// The decisive regression: with options shaped like the real compile
/// surfaces (small explicit set, large transitive closure), the FIRST
/// compile resolves the closure through the resolver and every subsequent
/// compile — same or different code — resolves NOTHING. Before the fix,
/// every compile re-resolved the full closure (measured: 74 fresh
/// PortableExecutableReferences per compile on Roslyn 5.0.0).
/// A probe wraps the DEFAULT resolver so the test also proves resolution
/// actually flows through this path at all (first > 0) — guarding against
/// the whole mechanism silently changing in a Roslyn upgrade.
///
[Fact]
public void Compile_RepeatAndDistinctScripts_ResolveClosureOnlyOnce()
{
var probe = new ProbeResolver(ScriptOptions.Default.MetadataResolver);
var caching = new CachingScriptMetadataResolver(probe);
var options = ScriptOptions.Default
.WithReferences(
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
.WithImports("System", "System.Linq")
.WithMetadataResolver(caching);
long CompileAndCount(string code)
{
long before = probe.MissingCalls;
var script = Microsoft.CodeAnalysis.CSharp.Scripting.CSharpScript.Create