Skip to content

Commit 7200baa

Browse files
Add project name normalization to match aspire's code generator logic (#6818)
* Add project name normalization to match aspire's code generator for --aspire projects. Add execution tests for the fix. * Address PR feedback: Test combine all available providers with aspire flag. More test cases coverage. * Add an [EnvironmentVariableSkipCondition] attribute for conditional tests. Make the Aspire project name theory conditional. * Regex logic and _Web append into one step. Rename test skip for clarity. --------- Co-authored-by: Jeff Handley <[email protected]>
1 parent e5b7f5d commit 7200baa

File tree

5 files changed

+164
-1
lines changed

5 files changed

+164
-1
lines changed

src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,12 @@
499499
"valueTransform": "vectorStoreIndexNameTransform",
500500
"replaces": "data-ChatWithCustomData-CSharp.Web-"
501501
},
502+
"aspireClassNameReplacer": {
503+
"type": "derived",
504+
"valueSource": "name",
505+
"valueTransform": "aspireClassName_ReplaceInvalidChars",
506+
"replaces": "ChatWithCustomData_CSharp_Web_AspireClassName"
507+
},
502508
"webProjectNamespaceAdjuster": {
503509
"type": "generated",
504510
"generator": "switch",
@@ -524,6 +530,12 @@
524530
}
525531
},
526532
"forms": {
533+
"aspireClassName_ReplaceInvalidChars": {
534+
"identifier": "replace",
535+
"pattern": "(((?<=\\.)|^)(?=\\d)|\\W)",
536+
"replacement": "_",
537+
"description": "Insert underscore before digits at start, or after a dot, or to replace non-word characters"
538+
},
527539
"vectorStoreIndexNameTransform": {
528540
"identifier": "chain",
529541
"steps": [

src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
#else // UseLocalVectorStore
5252
#endif
5353

54-
var webApp = builder.AddProject<Projects.ChatWithCustomData_CSharp_Web>("aichatweb-app");
54+
var webApp = builder.AddProject<Projects.ChatWithCustomData_CSharp_Web_AspireClassName_Web>("aichatweb-app");
5555
#if (IsOllama) // AI SERVICE PROVIDER REFERENCES
5656
webApp
5757
.WithReference(chat)

test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Threading.Tasks;
7+
using Microsoft.TestUtilities;
78
using Xunit;
89
using Xunit.Abstractions;
910

@@ -71,6 +72,44 @@ public async Task CreateRestoreAndBuild_AspireTemplate(params string[] args)
7172
await Fixture.BuildProjectAsync(project);
7273
}
7374

75+
/// <summary>
76+
/// Runs a single test with --aspire true and a project name that will trigger the class
77+
/// name normalization bug reported in https://github.com/dotnet/extensions/issues/6811.
78+
/// </summary>
79+
[Fact]
80+
public async Task CreateRestoreAndBuild_AspireProjectName()
81+
{
82+
await CreateRestoreAndBuild_AspireProjectName_Variants("azureopenai", "mix.ed-dash_name 123");
83+
}
84+
85+
/// <summary>
86+
/// Tests build for various project name formats, including dots and other
87+
/// separators, to trigger the class name normalization bug described
88+
/// in https://github.com/dotnet/extensions/issues/6811
89+
/// This runs for all provider combinations with --aspire true and different
90+
/// project names to ensure the bug is caught in all scenarios.
91+
/// </summary>
92+
/// <remarks>
93+
/// Because this test takes a long time to run, it is skipped by default. Set the
94+
/// environment variable <c>AI_TEMPLATES_TEST_PROJECT_NAMES</c> to "true" or "1"
95+
/// to enable it.
96+
/// </remarks>
97+
[ConditionalTheory]
98+
[EnvironmentVariableCondition("AI_TEMPLATES_TEST_PROJECT_NAMES", "true", "1")]
99+
[MemberData(nameof(GetAspireProjectNameVariants))]
100+
public async Task CreateRestoreAndBuild_AspireProjectName_Variants(string provider, string projectName)
101+
{
102+
var project = await Fixture.CreateProjectAsync(
103+
templateName: "aichatweb",
104+
projectName: projectName,
105+
args: new[] { "--aspire", $"--provider={provider}" });
106+
107+
project.StartupProjectRelativePath = $"{projectName}.AppHost";
108+
109+
await Fixture.RestoreProjectAsync(project);
110+
await Fixture.BuildProjectAsync(project);
111+
}
112+
74113
private static readonly (string name, string[] values)[] _templateOptions = [
75114
("--provider", ["azureopenai", "githubmodels", "ollama", "openai"]),
76115
("--vector-store", ["azureaisearch", "local", "qdrant"]),
@@ -158,4 +197,26 @@ private static IEnumerable<string[]> GetAllPossibleOptions(ReadOnlyMemory<(strin
158197
}
159198
}
160199
}
200+
201+
public static IEnumerable<object[]> GetAspireProjectNameVariants()
202+
{
203+
foreach (string provider in new[] { "ollama", "openai", "azureopenai", "githubmodels" })
204+
{
205+
foreach (string projectName in new[]
206+
{
207+
"mix.ed-dash_name 123",
208+
"dot.name",
209+
"project.123",
210+
"space name",
211+
".1My.Projec-",
212+
"1Project123",
213+
"11double",
214+
"1",
215+
"nomatch"
216+
})
217+
{
218+
yield return new object[] { provider, projectName };
219+
}
220+
}
221+
}
161222
}

test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
<PackageReference Include="Microsoft.TemplateEngine.TestHelper" />
1616
</ItemGroup>
1717

18+
<ItemGroup>
19+
<ProjectReference Include="..\..\TestUtilities\TestUtilities.csproj" />
20+
</ItemGroup>
21+
1822
<ItemGroup>
1923
<Compile Remove="Snapshots\**\*.*" />
2024
<None Include="Snapshots\**\*.*" />
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Linq;
6+
7+
namespace Microsoft.TestUtilities;
8+
9+
/// <summary>
10+
/// Skips a test based on the value of an environment variable.
11+
/// </summary>
12+
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)]
13+
public class EnvironmentVariableConditionAttribute : Attribute, ITestCondition
14+
{
15+
private string? _currentValue;
16+
17+
/// <summary>
18+
/// Initializes a new instance of the <see cref="EnvironmentVariableConditionAttribute"/> class.
19+
/// </summary>
20+
/// <param name="variableName">Name of the environment variable.</param>
21+
/// <param name="values">Value(s) of the environment variable to match for the condition.</param>
22+
/// <remarks>
23+
/// By default, the test will be run if the value of the variable matches any of the supplied values.
24+
/// Set <see cref="RunOnMatch"/> to <c>False</c> to run the test only if the value does not match.
25+
/// </remarks>
26+
public EnvironmentVariableConditionAttribute(string variableName, params string[] values)
27+
{
28+
if (string.IsNullOrEmpty(variableName))
29+
{
30+
throw new ArgumentException("Value cannot be null or empty.", nameof(variableName));
31+
}
32+
33+
if (values == null || values.Length == 0)
34+
{
35+
throw new ArgumentException("You must supply at least one value to match.", nameof(values));
36+
}
37+
38+
VariableName = variableName;
39+
Values = values;
40+
}
41+
42+
/// <summary>
43+
/// Gets or sets a value indicating whether the test should run if the value of the variable matches any
44+
/// of the supplied values. If <c>False</c>, the test runs only if the value does not match any of the
45+
/// supplied values. Default is <c>True</c>.
46+
/// </summary>
47+
public bool RunOnMatch { get; set; } = true;
48+
49+
/// <summary>
50+
/// Gets the name of the environment variable.
51+
/// </summary>
52+
public string VariableName { get; }
53+
54+
/// <summary>
55+
/// Gets the value(s) of the environment variable to match for the condition.
56+
/// </summary>
57+
public string[] Values { get; }
58+
59+
/// <summary>
60+
/// Gets a value indicating whether the condition is met for the configured environment variable and values.
61+
/// </summary>
62+
public bool IsMet
63+
{
64+
get
65+
{
66+
_currentValue ??= Environment.GetEnvironmentVariable(VariableName);
67+
var hasMatched = Values.Any(value => string.Equals(value, _currentValue, StringComparison.OrdinalIgnoreCase));
68+
69+
return RunOnMatch ? hasMatched : !hasMatched;
70+
}
71+
}
72+
73+
/// <summary>
74+
/// Gets a value indicating the reason the test was skipped.
75+
/// </summary>
76+
public string SkipReason
77+
{
78+
get
79+
{
80+
var value = _currentValue ?? "(null)";
81+
82+
return $"Test skipped on environment variable with name '{VariableName}' and value '{value}' " +
83+
$"for the '{nameof(RunOnMatch)}' value of '{RunOnMatch}'.";
84+
}
85+
}
86+
}

0 commit comments

Comments
 (0)