diff --git a/tests/DesignPatterns.SourceGenerators.Tests/DesignPatterns.SourceGenerators.Tests.csproj b/tests/DesignPatterns.SourceGenerators.Tests/DesignPatterns.SourceGenerators.Tests.csproj index 5006512..400a6e8 100644 --- a/tests/DesignPatterns.SourceGenerators.Tests/DesignPatterns.SourceGenerators.Tests.csproj +++ b/tests/DesignPatterns.SourceGenerators.Tests/DesignPatterns.SourceGenerators.Tests.csproj @@ -29,5 +29,14 @@ + + + + + + diff --git a/tests/DesignPatterns.SourceGenerators.Tests/DiagnosticCatalogConsistencyTests.cs b/tests/DesignPatterns.SourceGenerators.Tests/DiagnosticCatalogConsistencyTests.cs new file mode 100644 index 0000000..90a4708 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/DiagnosticCatalogConsistencyTests.cs @@ -0,0 +1,255 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Text; +using DesignPatterns.Diagnostics; +using Microsoft.CodeAnalysis; + +namespace DesignPatterns.SourceGenerators.Tests; + +/// +/// Keeps , , +/// and AnalyzerReleases markdown rows in lockstep (issue #318). +/// +public sealed class DiagnosticCatalogConsistencyTests +{ + private static readonly ImmutableArray Catalog = BuildCatalog(); + + // Group first so duplicate ids surface via the dedicated Fact instead of type-init ArgumentException. + private static readonly ImmutableDictionary CatalogById = + Catalog + .GroupBy(d => d.Id, StringComparer.Ordinal) + .ToImmutableDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + private static readonly ImmutableHashSet DiagnosticIdConstants = BuildDiagnosticIdConstants(); + + private static readonly ImmutableArray SourceGeneratorsUnshipped = + ParseEmbedded("AnalyzerReleases.SourceGenerators.Unshipped.md"); + + private static readonly ImmutableArray AnalyzersShipped = + ParseEmbedded("AnalyzerReleases.Analyzers.Shipped.md"); + + private static readonly ImmutableArray AnalyzersUnshipped = + ParseEmbedded("AnalyzerReleases.Analyzers.Unshipped.md"); + + public static TheoryData CatalogIds + { + get + { + var data = new TheoryData(); + foreach (var id in CatalogById.Keys.OrderBy(id => id, StringComparer.Ordinal)) + { + data.Add(id); + } + + return data; + } + } + + [Theory] + [MemberData(nameof(CatalogIds))] + public void Descriptor_has_exactly_one_SourceGenerators_Unshipped_row_with_matching_category_and_severity(string id) + { + var descriptor = CatalogById[id]; + var matches = SourceGeneratorsUnshipped.Where(r => r.Id == id).ToArray(); + + Assert.True( + matches.Length == 1, + FormattableString.Invariant( + $"Expected exactly one SourceGenerators Unshipped row for {id}, found {matches.Length}.")); + + var row = matches[0]; + Assert.Equal(descriptor.Category, row.Category); + Assert.Equal(descriptor.DefaultSeverity, row.Severity); + } + + [Theory] + [MemberData(nameof(CatalogIds))] + public void Descriptor_HelpLinkUri_matches_DiagnosticHelpLinks(string id) + { + var descriptor = CatalogById[id]; + Assert.Equal(DiagnosticHelpLinks.For(id), descriptor.HelpLinkUri); + } + + [Theory] + [MemberData(nameof(CatalogIds))] + public void Descriptor_MessageFormat_and_Description_are_non_empty(string id) + { + var descriptor = CatalogById[id]; + Assert.False(string.IsNullOrWhiteSpace(descriptor.MessageFormat.ToString())); + Assert.False(string.IsNullOrWhiteSpace(descriptor.Description?.ToString())); + } + + [Fact] + public void DiagnosticIds_and_descriptors_are_one_to_one() + { + var descriptorIds = CatalogById.Keys.ToImmutableHashSet(StringComparer.Ordinal); + var missingDescriptors = DiagnosticIdConstants.Except(descriptorIds).OrderBy(id => id, StringComparer.Ordinal).ToArray(); + var missingConstants = descriptorIds.Except(DiagnosticIdConstants).OrderBy(id => id, StringComparer.Ordinal).ToArray(); + + Assert.True( + missingDescriptors.Length == 0 && missingConstants.Length == 0, + "DiagnosticIds ↔ descriptors mismatch. " + + $"Ids without descriptor: [{string.Join(", ", missingDescriptors)}]. " + + $"Descriptors without DiagnosticIds constant: [{string.Join(", ", missingConstants)}]."); + } + + [Fact] + public void No_id_is_declared_by_more_than_one_descriptor() + { + var duplicates = Catalog + .GroupBy(d => d.Id, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + + Assert.True( + duplicates.Length == 0, + $"Duplicate descriptor ids: [{string.Join(", ", duplicates)}]."); + } + + [Fact] + public void SourceGenerators_Unshipped_rows_reference_only_catalog_ids() + { + var unknown = SourceGeneratorsUnshipped + .Select(r => r.Id) + .Where(id => !CatalogById.ContainsKey(id)) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + + Assert.True( + unknown.Length == 0, + $"SourceGenerators Unshipped rows not in catalog: [{string.Join(", ", unknown)}]."); + } + + [Fact] + public void Analyzers_Shipped_and_Unshipped_rows_are_subset_of_catalog_with_matching_category_and_severity() + { + AssertRowsAreSubsetOfCatalog(AnalyzersShipped, "Analyzers Shipped"); + AssertRowsAreSubsetOfCatalog(AnalyzersUnshipped, "Analyzers Unshipped"); + } + + [Fact] + public void No_id_appears_in_both_Analyzers_Shipped_and_Unshipped() + { + var shipped = AnalyzersShipped.Select(r => r.Id).ToImmutableHashSet(StringComparer.Ordinal); + var overlap = AnalyzersUnshipped + .Select(r => r.Id) + .Where(shipped.Contains) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + + Assert.True( + overlap.Length == 0, + $"Ids in both Analyzers Shipped and Unshipped: [{string.Join(", ", overlap)}]."); + } + + private static void AssertRowsAreSubsetOfCatalog(ImmutableArray rows, string label) + { + var failures = new List(); + foreach (var row in rows) + { + if (!CatalogById.TryGetValue(row.Id, out var descriptor)) + { + failures.Add(FormattableString.Invariant($"{row.Id}: not in catalog")); + continue; + } + + if (!string.Equals(descriptor.Category, row.Category, StringComparison.Ordinal)) + { + failures.Add(FormattableString.Invariant( + $"{row.Id}: category markdown '{row.Category}' != descriptor '{descriptor.Category}'")); + } + + if (descriptor.DefaultSeverity != row.Severity) + { + failures.Add(FormattableString.Invariant( + $"{row.Id}: severity markdown '{row.Severity}' != descriptor '{descriptor.DefaultSeverity}'")); + } + } + + Assert.True( + failures.Count == 0, + $"{label} subset/match failures: {string.Join("; ", failures)}"); + } + + private static ImmutableArray BuildCatalog() + { + var descriptors = new List(); + var type = typeof(DesignPatternsDiagnosticDescriptors); + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Static)) + { + if (property.PropertyType == typeof(DiagnosticDescriptor)) + { + descriptors.Add((DiagnosticDescriptor)property.GetValue(null)!); + continue; + } + + if (property.PropertyType == typeof(KeyedRegistrationDiagnostics)) + { + var group = (KeyedRegistrationDiagnostics)property.GetValue(null)!; + descriptors.Add(group.DuplicateKey); + descriptors.Add(group.ContractMismatch); + descriptors.Add(group.MissingParameterlessConstructor); + } + } + + return descriptors.ToImmutableArray(); + } + + private static ImmutableHashSet BuildDiagnosticIdConstants() + { + return typeof(DiagnosticIds) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)) + .Select(f => (string)f.GetRawConstantValue()!) + .ToImmutableHashSet(StringComparer.Ordinal); + } + + private static ImmutableArray ParseEmbedded(string resourceName) + { + var assembly = typeof(DiagnosticCatalogConsistencyTests).Assembly; + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Missing embedded resource '{resourceName}'."); + using var reader = new StreamReader(stream, Encoding.UTF8); + var text = reader.ReadToEnd(); + return ParseAnalyzerReleasesMarkdown(text); + } + + private static ImmutableArray ParseAnalyzerReleasesMarkdown(string text) + { + var rows = new List(); + foreach (var rawLine in text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Trim(); + if (!line.StartsWith("DP", StringComparison.Ordinal)) + { + continue; + } + + var parts = line.Split('|'); + if (parts.Length < 3) + { + continue; + } + + var id = parts[0].Trim(); + var category = parts[1].Trim(); + var severityText = parts[2].Trim(); + if (!Enum.TryParse(severityText, ignoreCase: true, out var severity)) + { + throw new InvalidOperationException( + FormattableString.Invariant( + $"Unrecognized severity '{severityText}' for {id} in AnalyzerReleases markdown.")); + } + + rows.Add(new AnalyzerReleaseRow(id, category, severity)); + } + + return rows.ToImmutableArray(); + } + + private readonly record struct AnalyzerReleaseRow(string Id, string Category, DiagnosticSeverity Severity); +}