Skip to content

Commit 1a54f21

Browse files
fix: Add unit tests and improve analyzers for handling generated code and diagnostics
1 parent e888619 commit 1a54f21

14 files changed

Lines changed: 408 additions & 33 deletions

IntelliTect.Analyzer/IntelliTect.Analyzer.Test/AsyncVoidTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,28 @@ public async Task Sample() { }
101101
await VerifyCSharpFix(test, fixTest, allowNewCompilerDiagnostics: true);
102102
}
103103

104+
[TestMethod]
105+
[Description("Analyzer should not crash when encountering non-async non-void methods")]
106+
public void NonAsyncNonVoidMethod_NoDiagnosticAndNoCrash()
107+
{
108+
// The analyzer uses 'as IMethodSymbol' then dereferences without null check.
109+
// This test ensures it handles the method symbol safely.
110+
string test = @"
111+
using System;
112+
113+
namespace ConsoleApplication1
114+
{
115+
class TypeName
116+
{
117+
public void SyncMethod() { }
118+
public int GetValue() => 42;
119+
public static void StaticMethod() { }
120+
}
121+
}";
122+
123+
VerifyCSharpDiagnostic(test);
124+
}
125+
104126
protected override CodeFixProvider GetCSharpCodeFixProvider()
105127
{
106128
return new CodeFixes.AsyncVoid();

IntelliTect.Analyzer/IntelliTect.Analyzer.Test/AttributesOnSeparateLinesTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,33 @@ static void Main()
390390
await VerifyCSharpFix(test, fixTest);
391391
}
392392

393+
[TestMethod]
394+
[Description("Analyzer should not report on generated code")]
395+
public void AttributesOnSameLine_InGeneratedCode_NoDiagnostic()
396+
{
397+
// AttributesOnSeparateLines uses GeneratedCodeAnalysisFlags.Analyze | ReportDiagnostics,
398+
// meaning it reports inside generated code. It should skip generated code.
399+
string test = @"using System;
400+
using System.CodeDom.Compiler;
401+
402+
namespace ConsoleApp
403+
{
404+
class AAttribute : Attribute { }
405+
class BAttribute : Attribute { }
406+
407+
[GeneratedCode(""tool"", ""1.0"")]
408+
class Program
409+
{
410+
[A][B]
411+
static void Main()
412+
{
413+
}
414+
}
415+
}";
416+
// Should NOT produce a diagnostic for attributes on same line inside generated code
417+
VerifyCSharpDiagnostic(test);
418+
}
419+
393420
private static DiagnosticResult GetExpectedDiagnosticResult(int line, int col)
394421
{
395422
return new DiagnosticResult

IntelliTect.Analyzer/IntelliTect.Analyzer.Test/DateTimeConversionTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,31 @@ static void Main(string[] args)
339339
});
340340
}
341341

342+
[TestMethod]
343+
[Description("Analyzer should not throw when DateTimeOffset/DateTime types are unresolvable")]
344+
public void AnyDateTimeOffsetConstructorWithUnresolvableTypes_DoesNotThrow()
345+
{
346+
// AnalyzeObjectCreation throws InvalidOperationException if GetTypeByMetadataName returns null.
347+
// Analyzer callbacks must never throw. This test uses code that won't resolve System types.
348+
string source = @"
349+
namespace ConsoleApp
350+
{
351+
class DateTimeOffset
352+
{
353+
public DateTimeOffset(object arg) { }
354+
}
355+
356+
class Program
357+
{
358+
static void Main()
359+
{
360+
var dto = new DateTimeOffset(null);
361+
}
362+
}
363+
}";
364+
VerifyCSharpDiagnostic(source);
365+
}
366+
342367
protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
343368
{
344369
return new Analyzers.BanImplicitDateTimeToDateTimeOffsetConversion();

IntelliTect.Analyzer/IntelliTect.Analyzer.Test/DiagnosticUriBuilderTests.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Diagnostics;
23
using Microsoft.VisualStudio.TestTools.UnitTesting;
34

45
namespace IntelliTect.Analyzer.Tests
@@ -19,5 +20,40 @@ public void GetUrl_GivenBlock00XXCode_ProperlyBuildsUrl(string title, string dia
1920
Assert.IsTrue(string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase),
2021
$"'{expected}' does not equal '{actual}'");
2122
}
23+
24+
[TestMethod]
25+
[Description("GetUrl allocates a new Regex on every call — performance regression test")]
26+
public void GetUrl_CalledRepeatedly_DoesNotAllocateExcessively()
27+
{
28+
// Warm up
29+
DiagnosticUrlBuilder.GetUrl("Test Title", "INTL0001");
30+
31+
long before = GC.GetAllocatedBytesForCurrentThread();
32+
33+
const int iterations = 1000;
34+
for (int i = 0; i < iterations; i++)
35+
{
36+
DiagnosticUrlBuilder.GetUrl("Test Title", "INTL0001");
37+
}
38+
39+
long after = GC.GetAllocatedBytesForCurrentThread();
40+
long bytesPerCall = (after - before) / iterations;
41+
42+
// A cached Regex or simple string.Replace should allocate ~200-400 bytes per call (strings only).
43+
// A new Regex() per call allocates ~2000+ bytes. Threshold at 500 to catch the Regex allocation.
44+
Assert.IsTrue(bytesPerCall < 500,
45+
$"GetUrl allocated ~{bytesPerCall} bytes/call, suggesting a new Regex is created each time. " +
46+
$"Expected < 500 bytes/call with a cached approach.");
47+
}
48+
49+
[TestMethod]
50+
[Description("Titles with multiple whitespace types should be hyphenated correctly")]
51+
public void GetUrl_TitleWithTabsAndMultipleSpaces_HyphenatesCorrectly()
52+
{
53+
string actual = DiagnosticUrlBuilder.GetUrl("Fields Multiple\tSpaces", "INTL9999");
54+
55+
Assert.IsTrue(actual.Contains("FIELDS--MULTIPLE-SPACES", StringComparison.OrdinalIgnoreCase),
56+
$"Expected all whitespace replaced with hyphens but got: '{actual}'");
57+
}
2258
}
2359
}

IntelliTect.Analyzer/IntelliTect.Analyzer.Test/FavorEnumeratorDirectoryCallsTests.cs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,5 +161,150 @@ static void Main(string[] args)
161161
";
162162
VerifyCSharpDiagnostic(source);
163163
}
164+
165+
[TestMethod]
166+
[Description("Cast<IdentifierNameSyntax>() throws InvalidCastException on generic method calls")]
167+
public void GenericMethodCallOnDirectory_DoesNotThrow()
168+
{
169+
// memberAccess.ChildNodes().Cast<IdentifierNameSyntax>() will throw
170+
// if any child node is not IdentifierNameSyntax (e.g. GenericNameSyntax).
171+
// This test uses a generic method call on a class named Directory.
172+
string source = @"
173+
using System;
174+
using System.Collections.Generic;
175+
176+
namespace ConsoleApp
177+
{
178+
public static class Directory
179+
{
180+
public static List<T> GetItems<T>() => new List<T>();
181+
}
182+
183+
class Program
184+
{
185+
static void Main(string[] args)
186+
{
187+
var items = Directory.GetItems<string>();
188+
}
189+
}
190+
}";
191+
VerifyCSharpDiagnostic(source);
192+
}
193+
194+
[TestMethod]
195+
[Description("Analyzer should not report when symbol is unresolved (compile error)")]
196+
public void UnresolvableDirectoryType_NoDiagnostic()
197+
{
198+
// When symbol.Symbol is null it means the code has a compile error,
199+
// not that it's System.IO.Directory. Should not produce a false positive.
200+
string source = @"
201+
namespace ConsoleApp
202+
{
203+
class Program
204+
{
205+
static void Main(string[] args)
206+
{
207+
var files = Directory.GetFiles(""."");
208+
}
209+
}
210+
}";
211+
// No 'using System.IO' so Directory is unresolvable — should NOT produce diagnostic
212+
VerifyCSharpDiagnostic(source);
213+
}
214+
215+
[TestMethod]
216+
[Description("Identifier comparison should use OrdinalIgnoreCase, not CurrentCultureIgnoreCase")]
217+
public void DirectoryIdentifier_CaseInsensitiveOrdinal_ProducesInfoMessage()
218+
{
219+
// Verifies that an oddly-cased but valid Directory.GetFiles call is still caught.
220+
// CurrentCultureIgnoreCase could fail in Turkish locale for identifiers with 'I'.
221+
string source = @"
222+
using System;
223+
using System.IO;
224+
225+
namespace ConsoleApp
226+
{
227+
class Program
228+
{
229+
static void Main(string[] args)
230+
{
231+
string[] files = Directory.GetFiles(""."");
232+
}
233+
}
234+
}";
235+
VerifyCSharpDiagnostic(source,
236+
new DiagnosticResult
237+
{
238+
Id = "INTL0301",
239+
Severity = DiagnosticSeverity.Info,
240+
Message = "Favor using the method `EnumerateFiles` over the `GetFiles` method",
241+
Locations =
242+
[
243+
new DiagnosticResultLocation("Test0.cs", 11, 30)
244+
]
245+
});
246+
}
247+
248+
[TestMethod]
249+
[Description("Analyzer misses fully-qualified System.IO.Directory.GetFiles()")]
250+
public void FullyQualifiedDirectoryGetFiles_ProducesInfoMessage()
251+
{
252+
// The analyzer only checks IdentifierNameSyntax, so System.IO.Directory.GetFiles()
253+
// is missed because the expression is a MemberAccessExpressionSyntax, not IdentifierNameSyntax.
254+
string source = @"
255+
using System;
256+
257+
namespace ConsoleApp
258+
{
259+
class Program
260+
{
261+
static void Main(string[] args)
262+
{
263+
string[] files = System.IO.Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory);
264+
}
265+
}
266+
}";
267+
VerifyCSharpDiagnostic(source,
268+
new DiagnosticResult
269+
{
270+
Id = "INTL0301",
271+
Severity = DiagnosticSeverity.Info,
272+
Message = "Favor using the method `EnumerateFiles` over the `GetFiles` method",
273+
Locations =
274+
[
275+
new DiagnosticResultLocation("Test0.cs", 10, 30)
276+
]
277+
});
278+
}
279+
280+
[TestMethod]
281+
[Description("Analyzer misses fully-qualified System.IO.Directory.GetDirectories()")]
282+
public void FullyQualifiedDirectoryGetDirectories_ProducesInfoMessage()
283+
{
284+
string source = @"
285+
using System;
286+
287+
namespace ConsoleApp
288+
{
289+
class Program
290+
{
291+
static void Main(string[] args)
292+
{
293+
string[] dirs = System.IO.Directory.GetDirectories(AppDomain.CurrentDomain.BaseDirectory);
294+
}
295+
}
296+
}";
297+
VerifyCSharpDiagnostic(source,
298+
new DiagnosticResult
299+
{
300+
Id = "INTL0302",
301+
Severity = DiagnosticSeverity.Info,
302+
Message = "Favor using the method `EnumerateDirectories` over the `GetDirectories` method",
303+
Locations =
304+
[
305+
new DiagnosticResultLocation("Test0.cs", 10, 29)
306+
]
307+
});
308+
}
164309
}
165310
}

IntelliTect.Analyzer/IntelliTect.Analyzer.Test/NamingFieldPascalUnderscoreTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Linq;
12
using System.Threading.Tasks;
23
using Microsoft.CodeAnalysis;
34
using Microsoft.CodeAnalysis.CodeFixes;
@@ -451,6 +452,60 @@ class TypeName
451452
VerifyCSharpDiagnostic(test, expected);
452453
}
453454

455+
[TestMethod]
456+
[Description("GeneratedCodeAttribute is checked by Name only, not full type — custom attribute with same name suppresses incorrectly")]
457+
public void FieldWithNamingViolation_CustomGeneratedCodeAttribute_ShouldStillWarn()
458+
{
459+
// A user-defined GeneratedCodeAttribute (different namespace) should NOT
460+
// suppress the naming diagnostic, but the current code checks by Name only.
461+
string test = @"
462+
using System;
463+
464+
namespace MyNamespace
465+
{
466+
class GeneratedCodeAttribute : Attribute { }
467+
468+
[GeneratedCode]
469+
class TypeName
470+
{
471+
public string myfield;
472+
}
473+
}";
474+
475+
var expected = new DiagnosticResult
476+
{
477+
Id = "INTL0001",
478+
Message = "Field 'myfield' should be named _PascalCase",
479+
Severity = DiagnosticSeverity.Warning,
480+
Locations =
481+
[
482+
new DiagnosticResultLocation("Test0.cs", 11, 27)
483+
]
484+
};
485+
486+
VerifyCSharpDiagnostic(test, expected);
487+
}
488+
489+
[TestMethod]
490+
[Description("Verify real GeneratedCodeAttribute still suppresses correctly")]
491+
public void FieldWithNamingViolation_RealGeneratedCodeAttribute_NoDiagnostic()
492+
{
493+
string test = @"
494+
using System;
495+
using System.CodeDom.Compiler;
496+
497+
namespace ConsoleApplication1
498+
{
499+
[GeneratedCode(""tool"", ""1.0"")]
500+
class TypeName
501+
{
502+
public string myfield;
503+
}
504+
}";
505+
506+
VerifyCSharpDiagnostic(test);
507+
}
508+
454509

455510
protected override CodeFixProvider GetCSharpCodeFixProvider()
456511
{

0 commit comments

Comments
 (0)