Skip to content

Commit 87c9c89

Browse files
committed
feat: add TemplateCompileTests to validate C# template generation
Introduced a new test class, TemplateCompileTests, to ensure that the generated content of C# and Razor templates parses correctly. This addition helps catch potential syntax errors in templates, improving the reliability of the component library. Also, added Microsoft.CodeAnalysis.CSharp package for syntax tree parsing.
1 parent fb6aaad commit 87c9c89

2 files changed

Lines changed: 155 additions & 0 deletions

File tree

ShellUI.Tests/ShellUI.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<ItemGroup>
1111
<PackageReference Include="coverlet.collector" Version="6.0.2" />
1212
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
13+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
1314
<PackageReference Include="xunit" Version="2.9.2" />
1415
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
1516
</ItemGroup>
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
using System.Linq;
2+
using System.Text.RegularExpressions;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using ShellUI.Templates;
6+
using Xunit;
7+
8+
namespace ShellUI.Tests;
9+
10+
/// Verifies that the *generated* content of each template parses as valid C#.
11+
/// For pure-.cs templates (variants), parse the whole content.
12+
/// For .razor templates, extract the @code { ... } block and parse its body.
13+
/// This catches the exact class of bug from shellui-fixes-for-lib.md (Fixes 2, 9, 10):
14+
/// unescaped quotes inside C# verbatim strings that ship as compile errors to consumers.
15+
public class TemplateCompileTests
16+
{
17+
[Theory]
18+
[InlineData("chart-variants")]
19+
[InlineData("alert-variants")]
20+
[InlineData("badge-variants")]
21+
[InlineData("button-variants")]
22+
public void CsharpTemplate_GeneratedContentParses(string componentName)
23+
{
24+
var content = ComponentRegistry.GetComponentContent(componentName);
25+
Assert.False(string.IsNullOrWhiteSpace(content), $"{componentName} template has no content");
26+
27+
var tree = CSharpSyntaxTree.ParseText(content!);
28+
var errors = tree.GetDiagnostics()
29+
.Where(d => d.Severity == DiagnosticSeverity.Error)
30+
.ToList();
31+
32+
Assert.True(errors.Count == 0,
33+
$"{componentName} generated content has {errors.Count} parse error(s):\n" +
34+
string.Join("\n", errors.Select(e => $" {e.Location.GetLineSpan().StartLinePosition}: {e.GetMessage()}")));
35+
}
36+
37+
[Theory]
38+
[InlineData("pie-chart")]
39+
[InlineData("dashboard-02")]
40+
[InlineData("button")]
41+
[InlineData("dialog")]
42+
public void RazorTemplate_CodeBlockParses(string componentName)
43+
{
44+
var content = ComponentRegistry.GetComponentContent(componentName);
45+
Assert.False(string.IsNullOrWhiteSpace(content), $"{componentName} template has no content");
46+
47+
var codeBlock = ExtractCodeBlock(content!);
48+
if (string.IsNullOrWhiteSpace(codeBlock))
49+
{
50+
// Brace extraction failed — almost always because an unterminated string
51+
// swallowed the closing brace. Surface a real diagnostic by parsing the
52+
// raw @code-onward suffix as if it were C#; the line/column points at
53+
// the actual offending escape.
54+
var raw = StripRazorDirectives(content!);
55+
var rawTree = CSharpSyntaxTree.ParseText($"class __Probe {{ {raw} }}");
56+
var rawErrors = rawTree.GetDiagnostics()
57+
.Where(d => d.Severity == DiagnosticSeverity.Error)
58+
// The only diagnostics that matter for this bug class are unterminated literals.
59+
.Where(d => d.Id is "CS1010" or "CS1002" or "CS1003" or "CS1026" or "CS1513" or "CS1525" or "CS1056")
60+
.Take(5)
61+
.ToList();
62+
Assert.Fail(
63+
$"{componentName} @code block could not be extracted — likely an unterminated " +
64+
$"string literal in the template. Diagnostics:\n" +
65+
string.Join("\n", rawErrors.Select(e => $" {e.Id} at {e.Location.GetLineSpan().StartLinePosition}: {e.GetMessage()}")));
66+
}
67+
68+
// Wrap in a synthetic class so the code block parses standalone.
69+
var wrapped = $"class __Probe {{ {codeBlock} }}";
70+
var tree = CSharpSyntaxTree.ParseText(wrapped);
71+
var errors = tree.GetDiagnostics()
72+
.Where(d => d.Severity == DiagnosticSeverity.Error)
73+
.ToList();
74+
75+
Assert.True(errors.Count == 0,
76+
$"{componentName} @code block has {errors.Count} parse error(s):\n" +
77+
string.Join("\n", errors.Select(e => $" {e.Location.GetLineSpan().StartLinePosition}: {e.GetMessage()}")));
78+
}
79+
80+
/// Strips Razor markup directives so the remaining text can be best-effort
81+
/// parsed as C#. Not a real Razor parser — just enough to surface useful
82+
/// diagnostics when ExtractCodeBlock fails.
83+
private static string StripRazorDirectives(string razor)
84+
{
85+
// Drop everything before the first @code keyword if present, else return as-is.
86+
var codeIdx = razor.IndexOf("@code", System.StringComparison.Ordinal);
87+
return codeIdx >= 0 ? razor.Substring(codeIdx + 5) : razor;
88+
}
89+
90+
/// Extracts the body of the first `@code { ... }` block, balancing braces.
91+
/// Returns null if no `@code` block is found.
92+
private static string? ExtractCodeBlock(string razor)
93+
{
94+
var match = Regex.Match(razor, @"@code\s*\{");
95+
if (!match.Success) return null;
96+
97+
var start = match.Index + match.Length;
98+
var depth = 1;
99+
var inString = false;
100+
var inVerbatimString = false;
101+
var inCharLiteral = false;
102+
var inLineComment = false;
103+
var inBlockComment = false;
104+
105+
for (var i = start; i < razor.Length; i++)
106+
{
107+
var c = razor[i];
108+
var next = i + 1 < razor.Length ? razor[i + 1] : '\0';
109+
110+
if (inLineComment)
111+
{
112+
if (c == '\n') inLineComment = false;
113+
continue;
114+
}
115+
if (inBlockComment)
116+
{
117+
if (c == '*' && next == '/') { inBlockComment = false; i++; }
118+
continue;
119+
}
120+
if (inVerbatimString)
121+
{
122+
if (c == '"' && next == '"') { i++; continue; } // escaped ""
123+
if (c == '"') inVerbatimString = false;
124+
continue;
125+
}
126+
if (inString)
127+
{
128+
if (c == '\\' && next != '\0') { i++; continue; }
129+
if (c == '"') inString = false;
130+
continue;
131+
}
132+
if (inCharLiteral)
133+
{
134+
if (c == '\\' && next != '\0') { i++; continue; }
135+
if (c == '\'') inCharLiteral = false;
136+
continue;
137+
}
138+
139+
if (c == '/' && next == '/') { inLineComment = true; i++; continue; }
140+
if (c == '/' && next == '*') { inBlockComment = true; i++; continue; }
141+
if (c == '@' && next == '"') { inVerbatimString = true; i++; continue; }
142+
if (c == '"') { inString = true; continue; }
143+
if (c == '\'') { inCharLiteral = true; continue; }
144+
145+
if (c == '{') depth++;
146+
else if (c == '}')
147+
{
148+
depth--;
149+
if (depth == 0) return razor.Substring(start, i - start);
150+
}
151+
}
152+
return null;
153+
}
154+
}

0 commit comments

Comments
 (0)