// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Reflection;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.RemoteHost;
using Aspire.TestUtilities;
using Aspire.TypeSystem;
using Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes;
namespace Aspire.Hosting.CodeGeneration.Python.Tests;
public class AtsPythonCodeGeneratorTests
{
private readonly AtsPythonCodeGenerator _generator = new();
// The test types are compiled into this assembly via Compile Include
private const string TestTypesAssemblyName = "Aspire.Hosting.CodeGeneration.Python.Tests";
[Fact]
public void Language_ReturnsPython()
{
Assert.Equal("Python", _generator.Language);
}
[Fact]
public async Task GenerateDistributedApplication_WithTestTypes_GeneratesCorrectOutput()
{
// Arrange
var atsContext = CreateContextFromTestAssembly();
// Act
var files = _generator.GenerateDistributedApplication(atsContext);
// Assert
Assert.Contains("aspire_app.py", files.Keys);
Assert.Contains("pyproject.toml", files.Keys);
await Verify(files["aspire_app.py"], extension: "py")
.UseFileName("AtsGeneratedAspire");
}
[Fact]
public void GenerateDistributedApplication_WithTestTypes_IncludesExportedValues()
{
var atsContext = CreateContextFromTestAssembly();
Assert.Contains(atsContext.ExportedValues, value => string.Join(".", value.PathSegments) == "TestConfigs.Default");
Assert.Contains(atsContext.ExportedValues, value => string.Join(".", value.PathSegments) == "TestConfigs.Profiles.Development");
var files = _generator.GenerateDistributedApplication(atsContext);
var aspirePy = files["aspire_app.py"];
Assert.Contains("TestConfigs = types.SimpleNamespace()", aspirePy);
Assert.Contains("TestConfigs.Default =", aspirePy);
Assert.Contains("TestConfigs.Profiles = types.SimpleNamespace()", aspirePy);
Assert.Contains("TestConfigs.Profiles.Development =", aspirePy);
}
[Fact]
public void GenerateDistributedApplication_WithTestTypes_IncludesCapabilities()
{
// Arrange
var capabilities = ScanCapabilitiesFromTestAssembly();
// Assert that capabilities are discovered
Assert.NotEmpty(capabilities);
// Check for specific capabilities (uses AssemblyName/methodName format)
// The test types are in TypeScript.Tests assembly
Assert.Contains(capabilities, c => c.CapabilityId == $"{TestTypesAssemblyName}/addTestRedis");
Assert.Contains(capabilities, c => c.CapabilityId == $"{TestTypesAssemblyName}/withPersistence");
Assert.Contains(capabilities, c => c.CapabilityId == $"{TestTypesAssemblyName}/withOptionalString");
}
[Fact]
public void GenerateDistributedApplication_WithTestTypes_DeriveCorrectMethodNames()
{
// Arrange
var capabilities = ScanCapabilitiesFromTestAssembly();
// Assert method names are derived correctly
var addTestRedis = capabilities.First(c => c.CapabilityId == $"{TestTypesAssemblyName}/addTestRedis");
Assert.Equal("addTestRedis", addTestRedis.MethodName);
var withPersistence = capabilities.First(c => c.CapabilityId == $"{TestTypesAssemblyName}/withPersistence");
Assert.Equal("withPersistence", withPersistence.MethodName);
}
[Fact]
public void GenerateDistributedApplication_WithTestTypes_CapturesParameters()
{
// Arrange
var capabilities = ScanCapabilitiesFromTestAssembly();
// Assert parameters are captured
var addTestRedis = capabilities.First(c => c.CapabilityId == $"{TestTypesAssemblyName}/addTestRedis");
Assert.Equal(2, addTestRedis.Parameters.Count);
Assert.Equal("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", addTestRedis.TargetTypeId);
Assert.Contains(addTestRedis.Parameters, p => p.Name == "name" && p.Type?.TypeId == "string");
Assert.Contains(addTestRedis.Parameters, p => p.Name == "port" && p.IsOptional);
}
[Fact]
public void Scanner_ReturnsBuilder_TrueForResourceBuilderReturnTypes()
{
// Verify that ReturnsBuilder is correctly set to true for methods
// that return IResourceBuilder<T>
var capabilities = ScanCapabilitiesFromTestAssembly();
// addTestRedis returns IResourceBuilder<TestRedisResource> - should have ReturnsBuilder = true
var addTestRedis = capabilities.FirstOrDefault(c => c.CapabilityId == $"{TestTypesAssemblyName}/addTestRedis");
Assert.NotNull(addTestRedis);
Assert.True(addTestRedis.ReturnsBuilder,
"addTestRedis returns IResourceBuilder<T> but ReturnsBuilder is false - fluent chaining won't work");
// withPersistence also returns IResourceBuilder<T>
var withPersistence = capabilities.FirstOrDefault(c => c.CapabilityId == $"{TestTypesAssemblyName}/withPersistence");
Assert.NotNull(withPersistence);
Assert.True(withPersistence.ReturnsBuilder,
"withPersistence returns IResourceBuilder<T> but ReturnsBuilder is false - fluent chaining won't work");
}
[Fact]
public async Task Scanner_AddTestRedis_HasCorrectTypeMetadata()
{
// Verify the entire capability object for addTestRedis
var capabilities = ScanCapabilitiesFromTestAssembly();
var addTestRedis = capabilities.FirstOrDefault(c => c.CapabilityId == $"{TestTypesAssemblyName}/addTestRedis");
Assert.NotNull(addTestRedis);
await Verify(addTestRedis).UseFileName("AddTestRedisCapability");
}
[Fact]
public async Task Scanner_WithPersistence_HasCorrectExpandedTargets()
{
// Verify the entire capability object for withPersistence
var capabilities = ScanCapabilitiesFromTestAssembly();
var withPersistence = capabilities.FirstOrDefault(c => c.CapabilityId == $"{TestTypesAssemblyName}/withPersistence");
Assert.NotNull(withPersistence);
await Verify(withPersistence).UseFileName("WithPersistenceCapability");
}
[Fact]
public async Task Scanner_WithOptionalString_HasCorrectExpandedTargets()
{
// Verify withOptionalString (targets IResource, should expand to TestRedisResource)
var capabilities = ScanCapabilitiesFromTestAssembly();
var withOptionalString = capabilities.FirstOrDefault(c => c.CapabilityId == $"{TestTypesAssemblyName}/withOptionalString");
Assert.NotNull(withOptionalString);
await Verify(withOptionalString).UseFileName("WithOptionalStringCapability");
}
[Fact]
public async Task Scanner_HostingAssembly_AddContainerCapability()
{
// Verify the addContainer capability from the real Aspire.Hosting assembly
var capabilities = ScanCapabilitiesFromHostingAssembly();
var addContainer = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/addContainer");
Assert.NotNull(addContainer);
await Verify(addContainer).UseFileName("HostingAddContainerCapability");
}
[Fact]
public void RuntimeType_ContainerResource_IsNotInterface()
{
// Verify that ContainerResource.IsInterface returns false using runtime reflection
var containerResourceType = typeof(ContainerResource);
Assert.NotNull(containerResourceType);
Assert.False(containerResourceType.IsInterface, "ContainerResource should NOT be an interface");
}
[Fact]
public void TwoPassScanning_DeduplicatesCapabilities()
{
// Verify that when the same capability appears in multiple assemblies,
// ScanAssemblies deduplicates by CapabilityId.
var capabilities = ScanCapabilitiesFromBothAssemblies();
// Each capability ID should appear only once
var duplicates = capabilities
.GroupBy(c => c.CapabilityId)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
Assert.Empty(duplicates);
}
[Fact]
public void TwoPassScanning_MergesHandleTypesFromAllAssemblies()
{
// Verify that ScanAssemblies collects handle types from all assemblies
var result = CreateContextFromBothAssemblies();
// Should have types from Aspire.Hosting (ContainerResource, etc.)
var containerResourceType = result.HandleTypes
.FirstOrDefault(t => t.AtsTypeId.Contains("ContainerResource") && !t.AtsTypeId.Contains("IContainer"));
Assert.NotNull(containerResourceType);
// Should have types from test assembly (TestRedisResource)
var testRedisType = result.HandleTypes
.FirstOrDefault(t => t.AtsTypeId.Contains("TestRedisResource"));
Assert.NotNull(testRedisType);
// TestRedisResource should have IResourceWithEnvironment in its interfaces
// (inherited via ContainerResource)
var hasEnvironmentInterface = testRedisType.ImplementedInterfaces
.Any(i => i.TypeId.Contains("IResourceWithEnvironment"));
Assert.True(hasEnvironmentInterface,
"TestRedisResource should implement IResourceWithEnvironment via ContainerResource");
}
[Fact]
public async Task TwoPassScanning_GeneratesWithEnvironmentOnTestRedisBuilder()
{
// End-to-end test: verify that environment methods appear on resources
// in the generated Python when using 2-pass scanning.
var atsContext = CreateContextFromBothAssemblies();
// Generate Python
var files = _generator.GenerateDistributedApplication(atsContext);
var aspirePy = files["aspire_app.py"];
// Verify environment-related methods appear (method names may vary by generator)
Assert.Contains("with_env", aspirePy);
// Snapshot for detailed verification
await Verify(aspirePy, extension: "py")
.UseFileName("TwoPassScanningGeneratedAspire");
}
[Fact]
public void GeneratedCode_UsesSnakeCaseMethodNames()
{
// Verify that the generated Python code uses snake_case for method names
var atsContext = CreateContextFromBothAssemblies();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspirePy = files["aspire_app.py"];
// Python should use snake_case, not camelCase
Assert.Contains("add_test_redis", aspirePy);
Assert.Contains("with_env", aspirePy);
Assert.DoesNotContain("addTestRedis(", aspirePy);
Assert.DoesNotContain("withEnv(", aspirePy);
}
[Fact]
public void GeneratedCode_HasCreateBuilderFunction()
{
// Verify that the generated Python code has a create_builder function
var atsContext = CreateContextFromBothAssemblies();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspirePy = files["aspire_app.py"];
Assert.Contains("def create_builder", aspirePy);
}
[Fact]
public void GeneratedCode_CreateBuilderDefaultsAppHostFilePathFromEnvironment()
{
var atsContext = CreateContextFromBothAssemblies();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspirePy = files["aspire_app.py"];
Assert.Contains("app_host_file_path: str | None = None", aspirePy);
Assert.Contains("effective_options['AppHostFilePath'] = app_host_file_path", aspirePy);
Assert.Contains("app_host_file_path = os.environ.get('ASPIRE_APPHOST_FILEPATH')", aspirePy);
}
[Fact]
public void GeneratedCode_UsesTypeHints()
{
// Verify that the generated Python code uses type hints
var atsContext = CreateContextFromBothAssemblies();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspirePy = files["aspire_app.py"];
// Python type hints use -> for return types and : for parameters
Assert.Contains("->", aspirePy);
Assert.Contains(": str", aspirePy);
}
[Fact]
public void GeneratedCode_SanitizesPythonKeywordIdentifiers()
{
var files = _generator.GenerateDistributedApplication(CreateContextWithKeywordParameter());
var aspirePy = files["aspire_app.py"];
Assert.Contains("with_from", aspirePy);
Assert.Contains("from_", aspirePy);
Assert.DoesNotContain("def with_from(self, from: str)", aspirePy);
Assert.DoesNotContain("\n from: str", aspirePy);
}
[Fact]
public void GeneratedCode_PreservesAcronymsInSnakeCaseIdentifiers()
{
var files = _generator.GenerateDistributedApplication(CreateContextWithAcronymIdentifiers());
var aspirePy = files["aspire_app.py"];
Assert.Contains("def with_something_ai(self, something_ai: str)", aspirePy);
Assert.DoesNotContain("with_something_a_i", aspirePy);
Assert.DoesNotContain("something_a_i", aspirePy);
}
[Fact]
public void GeneratedCode_SanitizesClrGenericNamesInInheritance()
{
var files = _generator.GenerateDistributedApplication(CreateContextWithGenericInheritance());
var aspirePy = files["aspire_app.py"];
Assert.DoesNotContain("Culture=neutral", aspirePy);
Assert.DoesNotContain("PublicKeyToken", aspirePy);
Assert.DoesNotContain("Version=", aspirePy);
}
[Fact]
public void GeneratedCode_DistinguishesOmittedAndExplicitNoneForNullableUnionParameters()
{
var files = _generator.GenerateDistributedApplication(CreateContextWithNullableUnionParameters());
var aspirePy = files["aspire_app.py"];
Assert.Contains(
"""
# Optional parameters with non-null defaults use this sentinel so omission remains distinct from explicit None.
_ASPIRE_UNSET = object()
""",
aspirePy);
Assert.Contains(
"def with_nullable_unions(client: AspireClient, optional_union: int | None | str = None, nullable_union: int | None | str = typing.cast(int | None | str, _ASPIRE_UNSET), nullable_items: typing.Iterable[int | None] | None = None)",
aspirePy);
Assert.Contains(
"""
if optional_union is not None:
rpc_args['optionalUnion'] = optional_union
if nullable_union is not _ASPIRE_UNSET:
rpc_args['nullableUnion'] = nullable_union
""",
aspirePy);
}
[Fact]
[RequiresTools(["python3"])]
[SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
public Task GeneratedCallback_WrapsGenericResourceBuilderHandleOnUnix()
=> GeneratedCallback_WrapsGenericResourceBuilderHandle("python3");
[Fact]
[RequiresTools(["python"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
public Task GeneratedCallback_WrapsGenericResourceBuilderHandleOnWindows()
=> GeneratedCallback_WrapsGenericResourceBuilderHandle("python");
private async Task GeneratedCallback_WrapsGenericResourceBuilderHandle(string pythonExecutable)
{
var files = _generator.GenerateDistributedApplication(CreateContextFromTestAssembly());
var tempDirectory = Directory.CreateTempSubdirectory();
try
{
var modulePath = Path.Combine(tempDirectory.FullName, "aspire_app.py");
var testPath = Path.Combine(tempDirectory.FullName, "test_callback.py");
await File.WriteAllTextAsync(modulePath, files["aspire_app.py"]);
await File.WriteAllTextAsync(
testPath,
"""
import aspire_app
client = aspire_app.AspireClient("unused")
invocations = []
def invoke_capability(capability_id, args, kwargs=None):
invocations.append(capability_id)
if capability_id.endswith("/withPythonBuilderCallback"):
callback = client._callback_registry[args["configure"]]
callback({
"p0": {
"$handle": "callback-resource",
"$type": "Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceBuilder`1[[Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource, Aspire.Hosting.CodeGeneration.Python.Tests, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]"
}
}, client)
return args["builder"]
client.invoke_capability = invoke_capability
resource = aspire_app.TestRedisResource(
aspire_app.Handle({
"$handle": "resource",
"$type": "Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource"
}),
client
)
resource.with_python_builder_callback(lambda configured: configured.with_persistence())
assert invocations == [
"Aspire.Hosting.CodeGeneration.Python.Tests/withPythonBuilderCallback",
"Aspire.Hosting.CodeGeneration.Python.Tests/withPersistence",
]
""");
using var process = new Process();
process.StartInfo = new ProcessStartInfo(pythonExecutable)
{
WorkingDirectory = tempDirectory.FullName,
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false
};
process.StartInfo.ArgumentList.Add(testPath);
process.Start();
var standardOutput = process.StandardOutput.ReadToEndAsync();
var standardError = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
Assert.True(
process.ExitCode == 0,
$"Python callback validation failed.{Environment.NewLine}{await standardOutput}{await standardError}");
}
finally
{
tempDirectory.Delete(recursive: true);
}
}
private static List<AtsCapabilityInfo> ScanCapabilitiesFromTestAssembly()
{
var testAssembly = LoadTestAssembly();
// Scan capabilities from the test assembly
var result = AtsCapabilityScanner.ScanAssembly(testAssembly);
return result.Capabilities;
}
private static AtsContext CreateContextFromTestAssembly()
{
var testAssembly = LoadTestAssembly();
// Scan capabilities from the test assembly
var result = AtsCapabilityScanner.ScanAssembly(testAssembly);
return result.ToAtsContext();
}
private static Assembly LoadTestAssembly()
{
// Get the test assembly at runtime (TypeScript tests assembly has the TestTypes)
return typeof(TestRedisResource).Assembly;
}
private static List<AtsCapabilityInfo> ScanCapabilitiesFromHostingAssembly()
{
var hostingAssembly = typeof(DistributedApplication).Assembly;
var result = AtsCapabilityScanner.ScanAssembly(hostingAssembly);
return result.Capabilities;
}
private static List<AtsCapabilityInfo> ScanCapabilitiesFromBothAssemblies()
{
var (testAssembly, hostingAssembly) = LoadBothAssemblies();
// Use ScanAssemblies for proper cross-assembly expansion
var result = AtsCapabilityScanner.ScanAssemblies([hostingAssembly, testAssembly]);
return result.Capabilities;
}
private static AtsContext CreateContextFromBothAssemblies()
{
var (testAssembly, hostingAssembly) = LoadBothAssemblies();
// Use ScanAssemblies for proper cross-assembly expansion and enum collection
var result = AtsCapabilityScanner.ScanAssemblies([hostingAssembly, testAssembly]);
return result.ToAtsContext();
}
private static (Assembly testAssembly, Assembly hostingAssembly) LoadBothAssemblies()
{
var testAssembly = typeof(TestRedisResource).Assembly;
var hostingAssembly = typeof(DistributedApplication).Assembly;
return (testAssembly, hostingAssembly);
}
private static AtsContext CreateContextWithKeywordParameter()
{
var resourceType = new AtsTypeRef
{
TypeId = "Tests/KeywordResource",
ClrType = typeof(KeywordResource),
Category = AtsTypeCategory.Handle
};
return new AtsContext
{
Capabilities =
[
new AtsCapabilityInfo
{
CapabilityId = "Tests/withFrom",
MethodName = "withFrom",
Parameters =
[
new AtsParameterInfo
{
Name = "builder",
Type = resourceType
},
new AtsParameterInfo
{
Name = "from",
Type = new AtsTypeRef
{
TypeId = AtsConstants.String,
Category = AtsTypeCategory.Primitive
}
}
],
ReturnType = resourceType,
TargetTypeId = resourceType.TypeId,
TargetType = resourceType,
TargetParameterName = "builder",
ExpandedTargetTypes = [resourceType],
ReturnsBuilder = true,
CapabilityKind = AtsCapabilityKind.Method
}
],
HandleTypes =
[
new AtsTypeInfo
{
AtsTypeId = resourceType.TypeId,
ClrType = typeof(KeywordResource)
}
],
DtoTypes = [],
EnumTypes = []
};
}
private static AtsContext CreateContextWithAcronymIdentifiers()
{
var resourceType = new AtsTypeRef
{
TypeId = "Tests/AcronymResource",
ClrType = typeof(AcronymResource),
Category = AtsTypeCategory.Handle
};
return new AtsContext
{
Capabilities =
[
new AtsCapabilityInfo
{
CapabilityId = "Tests/withSomethingAI",
MethodName = "withSomethingAI",
Parameters =
[
new AtsParameterInfo
{
Name = "builder",
Type = resourceType
},
new AtsParameterInfo
{
Name = "somethingAI",
Type = new AtsTypeRef
{
TypeId = AtsConstants.String,
Category = AtsTypeCategory.Primitive
}
}
],
ReturnType = resourceType,
TargetTypeId = resourceType.TypeId,
TargetType = resourceType,
TargetParameterName = "builder",
ExpandedTargetTypes = [resourceType],
ReturnsBuilder = true,
CapabilityKind = AtsCapabilityKind.Method
}
],
HandleTypes =
[
new AtsTypeInfo
{
AtsTypeId = resourceType.TypeId,
ClrType = typeof(AcronymResource)
}
],
DtoTypes = [],
EnumTypes = []
};
}
private static AtsContext CreateContextWithGenericInheritance()
{
var genericBaseType = typeof(GenericBaseResource<GenericTypeArgument<int, string>>);
var genericInterfaceType = typeof(IGenericResource<GenericTypeArgument<int, string>>);
var genericBaseTypeRef = new AtsTypeRef
{
TypeId = genericBaseType.AssemblyQualifiedName!,
ClrType = genericBaseType,
Category = AtsTypeCategory.Handle
};
var genericInterfaceTypeRef = new AtsTypeRef
{
TypeId = genericInterfaceType.AssemblyQualifiedName!,
ClrType = genericInterfaceType,
Category = AtsTypeCategory.Handle,
IsInterface = true
};
var resourceType = new AtsTypeRef
{
TypeId = "Tests/GenericResource",
ClrType = typeof(GenericResource),
Category = AtsTypeCategory.Handle,
BaseType = genericBaseTypeRef,
ImplementedInterfaces = [genericInterfaceTypeRef]
};
return new AtsContext
{
Capabilities =
[
new AtsCapabilityInfo
{
CapabilityId = "Tests/configureGenericResource",
MethodName = "configureGenericResource",
Parameters =
[
new AtsParameterInfo
{
Name = "builder",
Type = resourceType
}
],
ReturnType = new AtsTypeRef
{
TypeId = AtsConstants.Void,
Category = AtsTypeCategory.Primitive
},
TargetTypeId = resourceType.TypeId,
TargetType = resourceType,
TargetParameterName = "builder",
ExpandedTargetTypes = [resourceType],
CapabilityKind = AtsCapabilityKind.Method
}
],
HandleTypes =
[
new AtsTypeInfo
{
AtsTypeId = resourceType.TypeId,
ClrType = typeof(GenericResource),
BaseTypeHierarchy = [genericBaseTypeRef],
ImplementedInterfaces = [genericInterfaceTypeRef]
},
new AtsTypeInfo
{
AtsTypeId = genericBaseTypeRef.TypeId,
ClrType = genericBaseType
},
new AtsTypeInfo
{
AtsTypeId = genericInterfaceTypeRef.TypeId,
ClrType = genericInterfaceType,
IsInterface = true
}
],
DtoTypes = [],
EnumTypes = []
};
}
private static AtsContext CreateContextWithNullableUnionParameters()
{
var unionType = new AtsTypeRef
{
TypeId = "Tests/NullableUnion",
Category = AtsTypeCategory.Union,
UnionTypes =
[
new AtsTypeRef
{
TypeId = "Tests/NestedNullableUnion",
Category = AtsTypeCategory.Union,
UnionTypes =
[
new AtsTypeRef
{
TypeId = AtsConstants.Number,
Category = AtsTypeCategory.Primitive,
IsNullable = true
}
]
},
new AtsTypeRef
{
TypeId = AtsConstants.String,
Category = AtsTypeCategory.Primitive
}
]
};
return new AtsContext
{
Capabilities =
[
new AtsCapabilityInfo
{
CapabilityId = "Tests/withNullableUnions",
MethodName = "withNullableUnions",
Parameters =
[
new AtsParameterInfo
{
Name = "optionalUnion",
Type = unionType,
IsOptional = true
},
new AtsParameterInfo
{
Name = "nullableUnion",
Type = unionType,
IsOptional = true,
IsNullable = true,
DefaultValue = 1
},
new AtsParameterInfo
{
Name = "nullableItems",
Type = new AtsTypeRef
{
TypeId = "Tests/NullableItems",
Category = AtsTypeCategory.Array,
ElementType = new AtsTypeRef
{
TypeId = AtsConstants.Number,
Category = AtsTypeCategory.Primitive,
IsNullable = true
}
},
IsOptional = true
}
],
ReturnType = new AtsTypeRef
{
TypeId = AtsConstants.Void,
Category = AtsTypeCategory.Primitive
},
CapabilityKind = AtsCapabilityKind.Method
}
],
HandleTypes = [],
DtoTypes = [],
EnumTypes = []
};
}
[Fact]
public void GeneratedCode_DisambiguatesParameterMappingsWhenCapabilityIdMatchesMethodName()
{
// A capability declared with a bare [AspireExport] has a capability ID whose trailing segment
// is its method name, so the capability-ID fallback collapses onto the method name it was
// meant to escape. Builder classes are emitted in name order, so CollidingAlphaResource
// claims VolumeParameters and the bare CollidingBetaResource capability has to be qualified
// by its declaring namespace. Snapshot coverage cannot reach this: the shipped volume
// capabilities happen to emit in the opposite order.
var files = _generator.GenerateDistributedApplication(CreateContextWithCollidingParameterMappings());
// The generator composes output with StringBuilder.AppendLine, which writes Environment.NewLine,
// so the raw text is CRLF on Windows and LF elsewhere. Normalize before matching the multi-line
// expectations below, which assert exact field order and so cannot be collapsed to single lines.
var aspirePy = files["aspire_app.py"].ReplaceLineEndings("\n");
Assert.Contains("class VolumeParameters(typing.TypedDict, total=False):\n target: typing.Required[str]\n name: typing.Required[str]\n env: typing.Required[str]\n is_read_only: bool", aspirePy);
Assert.Contains("class TestsBetaVolumeParameters(typing.TypedDict, total=False):\n target: typing.Required[str]\n name: str\n is_read_only: bool", aspirePy);
}
private static AtsContext CreateContextWithCollidingParameterMappings()
{
var stringType = new AtsTypeRef
{
TypeId = AtsConstants.String,
Category = AtsTypeCategory.Primitive
};
var boolType = new AtsTypeRef
{
TypeId = AtsConstants.Boolean,
Category = AtsTypeCategory.Primitive
};
var projectType = new AtsTypeRef
{
TypeId = "Tests/CollidingAlphaResource",
ClrType = typeof(CollidingAlphaResource),
Category = AtsTypeCategory.Handle
};
var containerType = new AtsTypeRef
{
TypeId = "Tests/CollidingBetaResource",
ClrType = typeof(CollidingBetaResource),
Category = AtsTypeCategory.Handle
};
return new AtsContext
{
Capabilities =
[
// Emitted first (name order), so this claims VolumeParameters for its own shape.
new AtsCapabilityInfo
{
CapabilityId = "Tests.Alpha/withAlphaVolume",
MethodName = "withVolume",
Parameters =
[
new AtsParameterInfo { Name = "builder", Type = projectType },
new AtsParameterInfo { Name = "target", Type = stringType },
new AtsParameterInfo { Name = "name", Type = stringType },
new AtsParameterInfo { Name = "env", Type = stringType },
new AtsParameterInfo { Name = "isReadOnly", Type = boolType, IsOptional = true }
],
ReturnType = projectType,
TargetTypeId = projectType.TypeId,
TargetType = projectType,
TargetParameterName = "builder",
ExpandedTargetTypes = [projectType],
ReturnsBuilder = true,
CapabilityKind = AtsCapabilityKind.Method
},
// Bare-export shape: the capability ID also ends in withVolume, so it has no
// capability-ID fallback and must be qualified by its declaring namespace.
new AtsCapabilityInfo
{
CapabilityId = "Tests.Beta/withVolume",
MethodName = "withVolume",
Parameters =
[
new AtsParameterInfo { Name = "builder", Type = containerType },
new AtsParameterInfo { Name = "target", Type = stringType },
new AtsParameterInfo { Name = "name", Type = stringType, IsOptional = true },
new AtsParameterInfo { Name = "isReadOnly", Type = boolType, IsOptional = true }
],
ReturnType = containerType,
TargetTypeId = containerType.TypeId,
TargetType = containerType,
TargetParameterName = "builder",
ExpandedTargetTypes = [containerType],
ReturnsBuilder = true,
CapabilityKind = AtsCapabilityKind.Method
}
],
HandleTypes =
[
new AtsTypeInfo
{
AtsTypeId = projectType.TypeId,
ClrType = typeof(CollidingAlphaResource)
},
new AtsTypeInfo
{
AtsTypeId = containerType.TypeId,
ClrType = typeof(CollidingBetaResource)
}
],
DtoTypes = [],
EnumTypes = []
};
}
private sealed class KeywordResource;
private sealed class AcronymResource;
private interface IGenericResource<T>;
private abstract class GenericBaseResource<T>;
private sealed class GenericTypeArgument<TLeft, TRight>;
private sealed class GenericResource : GenericBaseResource<GenericTypeArgument<int, string>>, IGenericResource<GenericTypeArgument<int, string>>;
private sealed class CollidingAlphaResource : IResource
{
public string Name => nameof(CollidingAlphaResource);
public ResourceAnnotationCollection Annotations { get; } = [];
}
private sealed class CollidingBetaResource : IResource
{
public string Name => nameof(CollidingBetaResource);
public ResourceAnnotationCollection Annotations { get; } = [];
}
}