diff --git a/.editorconfig b/.editorconfig index 2cd5f5b1..fb1feb24 100644 --- a/.editorconfig +++ b/.editorconfig @@ -382,7 +382,7 @@ dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case # C++ files -[*.cpp,*.h,*.hpp,*.cc,*.hh,*.cxx,*.hxx] +[*.{cpp,h,hpp,cc,hh,cxx,hxx}] indent_style = tab # Naming convention rules (note: currently need to be ordered from more to less specific) diff --git a/CLAUDE.md b/CLAUDE.md index ff26f02e..ddedb972 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,11 +98,11 @@ All values are stored in SI base units, so operators read `.Value` directly. Sup ### File headers ```csharp -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. +// Copyright (c) 2023-2026 ktsu-dev contributors ``` +The text comes from `COPYRIGHT.md`; ktsu.Sdk syncs `.editorconfig`'s `file_header_template` from that file on every build, and `IDE0073` enforces it. If `COPYRIGHT.md` changes, update `GeneratorBase.WriteHeaderTo` to match — generator output is committed source, so a drift there shows up as a diff rather than a build error (`.g.cs` is exempt from `IDE0073`). `SourceGeneratorTests` asserts the emitted header, so the drift fails a test instead. + Generator-emitted files additionally carry `// `. ### Validation and error handling diff --git a/Directory.Packages.props b/Directory.Packages.props index 8eea6b87..d7523e21 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/Semantics.Color/Color.Operations.cs b/Semantics.Color/Color.Operations.cs index 5f2d5d21..a8871145 100644 --- a/Semantics.Color/Color.Operations.cs +++ b/Semantics.Color/Color.Operations.cs @@ -29,12 +29,15 @@ public double ContrastRatio(Color other) public AccessibilityLevel AccessibilityLevelAgainst(Color background, bool largeText = false) { double contrast = ContrastRatio(background); - if (contrast >= (largeText ? 4.5 : 7.0)) + double enhancedThreshold = largeText ? 4.5 : 7.0; + double minimumThreshold = largeText ? 3.0 : 4.5; + + if (contrast >= enhancedThreshold) { return AccessibilityLevel.AAA; } - return contrast >= (largeText ? 3.0 : 4.5) ? AccessibilityLevel.AA : AccessibilityLevel.Fail; + return contrast >= minimumThreshold ? AccessibilityLevel.AA : AccessibilityLevel.Fail; } /// diff --git a/Semantics.Color/Color.cs b/Semantics.Color/Color.cs index 972620be..2e409199 100644 --- a/Semantics.Color/Color.cs +++ b/Semantics.Color/Color.cs @@ -40,5 +40,5 @@ public readonly partial record struct Color(double R, double G, double B, double /// A float vector of the linear RGB channels. public Vector3 ToLinearVector3() => new((float)R, (float)G, (float)B); - internal static double Clamp01(double value) => value < 0.0 ? 0.0 : value > 1.0 ? 1.0 : value; + internal static double Clamp01(double value) => Math.Clamp(value, 0.0, 1.0); } diff --git a/Semantics.Color/Hsl.cs b/Semantics.Color/Hsl.cs index cd60a934..5f2b145f 100644 --- a/Semantics.Color/Hsl.cs +++ b/Semantics.Color/Hsl.cs @@ -146,7 +146,7 @@ public Srgb ToSrgb() /// The adjusted color. public Hsl OffsetHue(double degrees) => this with { H = NormalizeHue(H + degrees) }; - private static double Clamp01(double value) => value < 0.0 ? 0.0 : value > 1.0 ? 1.0 : value; + private static double Clamp01(double value) => Math.Clamp(value, 0.0, 1.0); internal static double NormalizeHue(double h) { diff --git a/Semantics.Color/Semantics.Color.csproj b/Semantics.Color/Semantics.Color.csproj index 3b4c90a3..f23abc6d 100644 --- a/Semantics.Color/Semantics.Color.csproj +++ b/Semantics.Color/Semantics.Color.csproj @@ -9,6 +9,10 @@ + + + diff --git a/Semantics.Music/Semantics.Music.csproj b/Semantics.Music/Semantics.Music.csproj index 2c7afde5..45351162 100644 --- a/Semantics.Music/Semantics.Music.csproj +++ b/Semantics.Music/Semantics.Music.csproj @@ -8,6 +8,10 @@ + + + diff --git a/Semantics.Paths/Implementations/RelativeDirectoryPath.cs b/Semantics.Paths/Implementations/RelativeDirectoryPath.cs index b8e681d6..76172fb6 100644 --- a/Semantics.Paths/Implementations/RelativeDirectoryPath.cs +++ b/Semantics.Paths/Implementations/RelativeDirectoryPath.cs @@ -140,16 +140,6 @@ public AbsoluteDirectoryPath AsAbsolute() return AbsoluteDirectoryPath.Create(absolutePath); } - /// - /// Explicitly implements IRelativePath.AsAbsolute() to return the base AbsolutePath type. - /// - /// An representing this absolute path. - AbsolutePath IRelativePath.AsAbsolute() - { - string absolutePath = Path.GetFullPath(WeakString); - return AbsolutePath.Create(absolutePath); - } - /// /// Converts this relative directory path to an absolute directory path using the specified base directory. /// @@ -167,6 +157,16 @@ public AbsoluteDirectoryPath AsAbsolute(AbsoluteDirectoryPath baseDirectory) return AbsoluteDirectoryPath.Create(absolutePath); } + /// + /// Explicitly implements IRelativePath.AsAbsolute() to return the base AbsolutePath type. + /// + /// An representing this absolute path. + AbsolutePath IRelativePath.AsAbsolute() + { + string absolutePath = Path.GetFullPath(WeakString); + return AbsolutePath.Create(absolutePath); + } + /// /// Converts this relative directory path to a relative directory path using the specified base directory. /// Since this is already a relative path, returns itself. diff --git a/Semantics.Paths/Implementations/RelativeFilePath.cs b/Semantics.Paths/Implementations/RelativeFilePath.cs index cc469ce4..a359705f 100644 --- a/Semantics.Paths/Implementations/RelativeFilePath.cs +++ b/Semantics.Paths/Implementations/RelativeFilePath.cs @@ -74,16 +74,6 @@ public AbsoluteFilePath AsAbsolute() return AbsoluteFilePath.Create(absolutePath); } - /// - /// Explicitly implements IRelativePath.AsAbsolute() to return the base AbsolutePath type. - /// - /// An representing this absolute path. - AbsolutePath IRelativePath.AsAbsolute() - { - string absolutePath = Path.GetFullPath(WeakString); - return AbsolutePath.Create(absolutePath); - } - /// /// Converts this relative file path to an absolute file path using the specified base directory. /// @@ -101,6 +91,16 @@ public AbsoluteFilePath AsAbsolute(AbsoluteDirectoryPath baseDirectory) return AbsoluteFilePath.Create(absolutePath); } + /// + /// Explicitly implements IRelativePath.AsAbsolute() to return the base AbsolutePath type. + /// + /// An representing this absolute path. + AbsolutePath IRelativePath.AsAbsolute() + { + string absolutePath = Path.GetFullPath(WeakString); + return AbsolutePath.Create(absolutePath); + } + /// /// Converts this relative file path to a relative file path using the specified base directory. /// Since this is already a relative path, returns itself. diff --git a/Semantics.Paths/Semantics.Paths.csproj b/Semantics.Paths/Semantics.Paths.csproj index 29ce8a9b..e81c01e5 100644 --- a/Semantics.Paths/Semantics.Paths.csproj +++ b/Semantics.Paths/Semantics.Paths.csproj @@ -13,7 +13,10 @@ - + + + diff --git a/Semantics.SourceGenerators/AssemblyInfo.cs b/Semantics.SourceGenerators/AssemblyInfo.cs new file mode 100644 index 00000000..bd93b3a5 --- /dev/null +++ b/Semantics.SourceGenerators/AssemblyInfo.cs @@ -0,0 +1,5 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ktsu.Semantics.Test")] diff --git a/Semantics.SourceGenerators/Templates/PropertyTemplate.cs b/Semantics.SourceGenerators/Templates/PropertyTemplate.cs index 7a897839..22d33470 100644 --- a/Semantics.SourceGenerators/Templates/PropertyTemplate.cs +++ b/Semantics.SourceGenerators/Templates/PropertyTemplate.cs @@ -7,9 +7,11 @@ namespace Semantics.SourceGenerators.Templates; internal class PropertyTemplate : MemberTemplate { - public static Action AutoGet = (sw) => sw.Write("get;"); - public static Action AutoSet = (sw) => sw.Write("set;"); - public static Action AutoInit = (sw) => sw.Write("init;"); + // Compared by reference in WriteTo to detect auto-property shorthand, so these must stay + // single fixed instances; readonly enforces that without changing the comparison. + public static readonly Action AutoGet = (sw) => sw.Write("get;"); + public static readonly Action AutoSet = (sw) => sw.Write("set;"); + public static readonly Action AutoInit = (sw) => sw.Write("init;"); public Action? GetterFactory { get; set; } public Action? SetterFactory { get; set; } diff --git a/Semantics.Strings.Identifiers/Semantics.Strings.Identifiers.csproj b/Semantics.Strings.Identifiers/Semantics.Strings.Identifiers.csproj index 23ac4f0c..6249db5f 100644 --- a/Semantics.Strings.Identifiers/Semantics.Strings.Identifiers.csproj +++ b/Semantics.Strings.Identifiers/Semantics.Strings.Identifiers.csproj @@ -7,7 +7,10 @@ - + + + diff --git a/Semantics.Strings/Semantics.Strings.csproj b/Semantics.Strings/Semantics.Strings.csproj index 0b0413c7..4f1f9c71 100644 --- a/Semantics.Strings/Semantics.Strings.csproj +++ b/Semantics.Strings/Semantics.Strings.csproj @@ -15,7 +15,10 @@ - + + + diff --git a/Semantics.Strings/Validation/Attributes/Casing/IsKebabCaseAttribute.cs b/Semantics.Strings/Validation/Attributes/Casing/IsKebabCaseAttribute.cs index 7daa592f..0aa0f96f 100644 --- a/Semantics.Strings/Validation/Attributes/Casing/IsKebabCaseAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/Casing/IsKebabCaseAttribute.cs @@ -27,6 +27,8 @@ public sealed class IsKebabCaseAttribute : NativeSemanticStringValidationAttribu /// private sealed class KebabCaseValidator : ValidationAdapter { + private const string FailureMessage = "The value must be in kebab-case format."; + /// /// Validates that a string is in kebab-case. /// @@ -40,27 +42,27 @@ protected override ValidationResult ValidateValue(string value) } // Cannot start or end with hyphen - if (value.StartsWith("-") || value.EndsWith("-")) + if (value.StartsWith('-') || value.EndsWith('-')) { - return ValidationResult.Failure("The value must be in kebab-case format."); + return ValidationResult.Failure(FailureMessage); } // Cannot have consecutive hyphens if (value.Contains("--")) { - return ValidationResult.Failure("The value must be in kebab-case format."); + return ValidationResult.Failure(FailureMessage); } // No spaces, underscores, or other separators allowed (except hyphens) if (value.Any(c => char.IsWhiteSpace(c) || c == '_')) { - return ValidationResult.Failure("The value must be in kebab-case format."); + return ValidationResult.Failure(FailureMessage); } // All characters must be lowercase letters, digits, or hyphens if (!value.All(c => char.IsLower(c) || char.IsDigit(c) || c == '-')) { - return ValidationResult.Failure("The value must be in kebab-case format."); + return ValidationResult.Failure(FailureMessage); } return ValidationResult.Success(); diff --git a/Semantics.Strings/Validation/Attributes/Casing/IsMacroCaseAttribute.cs b/Semantics.Strings/Validation/Attributes/Casing/IsMacroCaseAttribute.cs index 7a037b7d..d1f3581e 100644 --- a/Semantics.Strings/Validation/Attributes/Casing/IsMacroCaseAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/Casing/IsMacroCaseAttribute.cs @@ -28,6 +28,8 @@ public sealed class IsMacroCaseAttribute : NativeSemanticStringValidationAttribu /// private sealed class MacroCaseValidator : ValidationAdapter { + private const string FailureMessage = "The value must be in MACRO_CASE format."; + /// /// Validates that a string is in MACRO_CASE. /// @@ -41,27 +43,27 @@ protected override ValidationResult ValidateValue(string value) } // Cannot start or end with underscore - if (value.StartsWith("_") || value.EndsWith("_")) + if (value.StartsWith('_') || value.EndsWith('_')) { - return ValidationResult.Failure("The value must be in MACRO_CASE format."); + return ValidationResult.Failure(FailureMessage); } // Cannot have consecutive underscores if (value.Contains("__")) { - return ValidationResult.Failure("The value must be in MACRO_CASE format."); + return ValidationResult.Failure(FailureMessage); } // No spaces, hyphens, or other separators allowed (except underscores) if (value.Any(c => char.IsWhiteSpace(c) || c == '-')) { - return ValidationResult.Failure("The value must be in MACRO_CASE format."); + return ValidationResult.Failure(FailureMessage); } // All characters must be uppercase letters, digits, or underscores if (!value.All(c => char.IsUpper(c) || char.IsDigit(c) || c == '_')) { - return ValidationResult.Failure("The value must be in MACRO_CASE format."); + return ValidationResult.Failure(FailureMessage); } return ValidationResult.Success(); diff --git a/Semantics.Strings/Validation/Attributes/Casing/IsSentenceCaseAttribute.cs b/Semantics.Strings/Validation/Attributes/Casing/IsSentenceCaseAttribute.cs index e72470a4..6ff41593 100644 --- a/Semantics.Strings/Validation/Attributes/Casing/IsSentenceCaseAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/Casing/IsSentenceCaseAttribute.cs @@ -27,6 +27,8 @@ public sealed class IsSentenceCaseAttribute : NativeSemanticStringValidationAttr /// private sealed class SentenceCaseValidator : ValidationAdapter { + private const string FailureMessage = "The value must be in sentence case format."; + /// /// Validates that a string is in sentence case. /// @@ -43,7 +45,7 @@ protected override ValidationResult ValidateValue(string value) char? firstLetter = value.FirstOrDefault(char.IsLetter); if (firstLetter.HasValue && !char.IsUpper(firstLetter.Value)) { - return ValidationResult.Failure("The value must be in sentence case format."); + return ValidationResult.Failure(FailureMessage); } // Check that all other letters after the first are lowercase @@ -60,7 +62,7 @@ protected override ValidationResult ValidateValue(string value) if (char.IsUpper(c)) { - return ValidationResult.Failure("The value must be in sentence case format."); + return ValidationResult.Failure(FailureMessage); } } } diff --git a/Semantics.Strings/Validation/Attributes/Casing/IsSnakeCaseAttribute.cs b/Semantics.Strings/Validation/Attributes/Casing/IsSnakeCaseAttribute.cs index f53c5598..2e0db51f 100644 --- a/Semantics.Strings/Validation/Attributes/Casing/IsSnakeCaseAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/Casing/IsSnakeCaseAttribute.cs @@ -27,6 +27,8 @@ public sealed class IsSnakeCaseAttribute : NativeSemanticStringValidationAttribu /// private sealed class SnakeCaseValidator : ValidationAdapter { + private const string FailureMessage = "The value must be in snake_case format."; + /// /// Validates that a string is in snake_case. /// @@ -40,27 +42,27 @@ protected override ValidationResult ValidateValue(string value) } // Cannot start or end with underscore - if (value.StartsWith("_") || value.EndsWith("_")) + if (value.StartsWith('_') || value.EndsWith('_')) { - return ValidationResult.Failure("The value must be in snake_case format."); + return ValidationResult.Failure(FailureMessage); } // Cannot have consecutive underscores if (value.Contains("__")) { - return ValidationResult.Failure("The value must be in snake_case format."); + return ValidationResult.Failure(FailureMessage); } // No spaces, hyphens, or other separators allowed (except underscores) if (value.Any(c => char.IsWhiteSpace(c) || c == '-')) { - return ValidationResult.Failure("The value must be in snake_case format."); + return ValidationResult.Failure(FailureMessage); } // All characters must be lowercase letters, digits, or underscores if (!value.All(c => char.IsLower(c) || char.IsDigit(c) || c == '_')) { - return ValidationResult.Failure("The value must be in snake_case format."); + return ValidationResult.Failure(FailureMessage); } return ValidationResult.Success(); diff --git a/Semantics.Strings/Validation/Attributes/FirstClassTypes/IsDateTimeAttribute.cs b/Semantics.Strings/Validation/Attributes/FirstClassTypes/IsDateTimeAttribute.cs index 9cc47cad..15a4b5eb 100644 --- a/Semantics.Strings/Validation/Attributes/FirstClassTypes/IsDateTimeAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/FirstClassTypes/IsDateTimeAttribute.cs @@ -43,7 +43,9 @@ protected override ValidationResult ValidateValue(string value) return ValidationResult.Success(); } - bool isValid = DateTime.TryParse(value, out _); + // CurrentCulture is stated explicitly rather than switching to InvariantCulture: it is + // what the provider-less overload already used, so validation behaviour is unchanged. + bool isValid = DateTime.TryParse(value, System.Globalization.CultureInfo.CurrentCulture, System.Globalization.DateTimeStyles.None, out _); return isValid ? ValidationResult.Success() : ValidationResult.Failure("The value must be a valid DateTime."); diff --git a/Semantics.Strings/Validation/Attributes/FirstClassTypes/IsTimeSpanAttribute.cs b/Semantics.Strings/Validation/Attributes/FirstClassTypes/IsTimeSpanAttribute.cs index 91597cce..da53e196 100644 --- a/Semantics.Strings/Validation/Attributes/FirstClassTypes/IsTimeSpanAttribute.cs +++ b/Semantics.Strings/Validation/Attributes/FirstClassTypes/IsTimeSpanAttribute.cs @@ -43,7 +43,9 @@ protected override ValidationResult ValidateValue(string value) return ValidationResult.Success(); } - bool isValid = TimeSpan.TryParse(value, out _); + // CurrentCulture is stated explicitly rather than switching to InvariantCulture: it is + // what the provider-less overload already used, so validation behaviour is unchanged. + bool isValid = TimeSpan.TryParse(value, System.Globalization.CultureInfo.CurrentCulture, out _); return isValid ? ValidationResult.Success() : ValidationResult.Failure("The value must be a valid TimeSpan."); diff --git a/Semantics.Test/CasingValidatorsTests.cs b/Semantics.Test/CasingValidatorsTests.cs index 89101562..669344fe 100644 --- a/Semantics.Test/CasingValidatorsTests.cs +++ b/Semantics.Test/CasingValidatorsTests.cs @@ -111,4 +111,32 @@ public void MacroCase_Valid_Invalid_Empty() MacroCaseString empty = SemanticString.Create(""); Assert.AreEqual("", empty.WeakString); } + + /// + /// Covers the two rejection branches each casing validator has beyond the leading/trailing + /// separator checks: a separator belonging to a different convention, and correct separators + /// with the wrong letter case. + /// + [TestMethod] + public void CasingValidators_RejectForeignSeparatorsAndWrongCase() + { + AssertRejects("hello world", "hello_world", "Hello-World"); + AssertRejects("hello world", "hello-world", "Hello_World"); + AssertRejects("HELLO WORLD", "HELLO-WORLD", "Hello_World"); + + // Sentence case: first letter must be uppercase, and leading non-letters are skipped when + // locating it. + AssertRejects("hello world", "123 hello"); + } + + private static void AssertRejects(params string[] values) + where TString : SemanticString + { + foreach (string value in values) + { + Assert.ThrowsExactly( + () => SemanticString.Create(value), + $"{typeof(TString).Name} should reject \"{value}\"."); + } + } } diff --git a/Semantics.Test/Paths/PathInterfaceMemberTests.cs b/Semantics.Test/Paths/PathInterfaceMemberTests.cs index d751279b..cd6d050e 100644 --- a/Semantics.Test/Paths/PathInterfaceMemberTests.cs +++ b/Semantics.Test/Paths/PathInterfaceMemberTests.cs @@ -109,4 +109,23 @@ public void CompareTo_ThroughIComparableOfIPath_ComparesByValue() Assert.IsLessThan(0, first.CompareTo(second)); Assert.IsGreaterThan(0, first.CompareTo(null)); } + + /// + /// The relative path types implement explicitly so the + /// interface returns the base while the public overload returns the + /// specific file or directory type. Only the explicit implementations are exercised here; the + /// public overloads are covered elsewhere. + /// + [TestMethod] + public void AsAbsolute_ThroughIRelativePath_ReturnsBaseAbsolutePath() + { + IRelativePath relativeFile = RelativeFilePath.Create(Path.Combine("sub", "afile.txt")); + IRelativePath relativeDirectory = RelativeDirectoryPath.Create(Path.Combine("sub", "child")); + + AbsolutePath absoluteFile = relativeFile.AsAbsolute(); + AbsolutePath absoluteDirectory = relativeDirectory.AsAbsolute(); + + Assert.AreEqual(Path.GetFullPath(Path.Combine("sub", "afile.txt")), absoluteFile.WeakString); + Assert.AreEqual(Path.GetFullPath(Path.Combine("sub", "child")), absoluteDirectory.WeakString); + } } diff --git a/Semantics.Test/Quantities/PropertyTemplateTests.cs b/Semantics.Test/Quantities/PropertyTemplateTests.cs new file mode 100644 index 00000000..70f228b6 --- /dev/null +++ b/Semantics.Test/Quantities/PropertyTemplateTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test.Quantities; + +using ktsu.CodeBlocker; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using global::Semantics.SourceGenerators.Templates; + +/// +/// Covers 's auto-property shorthand emission. +/// +/// +/// , and +/// are the accessor factories the template compares against +/// by reference to decide whether a property can be written in shorthand. No generator assigns them +/// today, so the shorthand branches are currently unreachable from production code — these tests pin +/// the intended behaviour so the affordance is exercised rather than silently rotting. +/// +[TestClass] +public class PropertyTemplateTests +{ + [TestMethod] + public void WriteTo_AutoGetAndAutoSet_EmitsShorthand() + { + string output = Emit(new PropertyTemplate + { + Type = "int", + Name = "Count", + GetterFactory = PropertyTemplate.AutoGet, + SetterFactory = PropertyTemplate.AutoSet, + }); + + Assert.Contains("int Count { get; set; }", output); + } + + [TestMethod] + public void WriteTo_AutoGetAndAutoInit_EmitsInitShorthand() + { + string output = Emit(new PropertyTemplate + { + Type = "string", + Name = "Symbol", + GetterFactory = PropertyTemplate.AutoGet, + SetterFactory = PropertyTemplate.AutoInit, + }); + + Assert.Contains("string Symbol { get; init; }", output); + } + + [TestMethod] + public void WriteTo_NoAccessors_EmitsAbstractProperty() + { + string output = Emit(new PropertyTemplate + { + Type = "double", + Name = "Value", + }); + + Assert.Contains("double Value;", output); + } + + [TestMethod] + public void WriteTo_CustomGetter_EmitsFullBody() + { + string output = Emit(new PropertyTemplate + { + Type = "int", + Name = "Doubled", + GetterFactory = (cb) => cb.Write("get => field * 2;"), + }); + + Assert.Contains("get => field * 2;", output); + Assert.DoesNotContain("{ get;", output, "A custom getter must not be written as auto-property shorthand."); + } + + private static string Emit(PropertyTemplate template) + { + using CodeBlocker codeBlocker = CodeBlocker.Create(); + template.WriteTo(codeBlocker); + return codeBlocker.ToString(); + } +} diff --git a/Semantics.Test/Quantities/SourceGeneratorTests.cs b/Semantics.Test/Quantities/SourceGeneratorTests.cs index 44932582..d920d0ae 100644 --- a/Semantics.Test/Quantities/SourceGeneratorTests.cs +++ b/Semantics.Test/Quantities/SourceGeneratorTests.cs @@ -33,46 +33,53 @@ public class SourceGeneratorTests /// /// Every generator paired with the metadata file it consumes. /// - private static IEnumerable Generators => + private static IEnumerable<(IIncrementalGenerator Generator, string MetadataFileName)> Generators => [ - [new ConversionsGenerator(), "conversions.json"], - [new DimensionsGenerator(), "dimensions.json"], - [new LogarithmicScalesGenerator(), "logarithmic.json"], - [new MagnitudesGenerator(), "magnitudes.json"], - [new PhysicalConstantsGenerator(), "domains.json"], - [new PrecisionGenerator(), "precision.json"], - [new QuantitiesGenerator(), "dimensions.json"], - [new UnitsGenerator(), "units.json"], + (new ConversionsGenerator(), "conversions.json"), + (new DimensionsGenerator(), "dimensions.json"), + (new LogarithmicScalesGenerator(), "logarithmic.json"), + (new MagnitudesGenerator(), "magnitudes.json"), + (new PhysicalConstantsGenerator(), "domains.json"), + (new PrecisionGenerator(), "precision.json"), + (new QuantitiesGenerator(), "dimensions.json"), + (new UnitsGenerator(), "units.json"), ]; + // Iterates inside the test rather than using [DynamicData] with an IIncrementalGenerator + // parameter: a public test method taking a reference type trips CA1062, and neither escape is + // available here - CA1510 rejects an explicit ArgumentNullException throw, CA1062 does not + // recognise ArgumentNullException.ThrowIfNull, and Polyfill's Ensure cannot be referenced + // because its shim types collide with the ones the generator assembly embeds publicly. + // Assertion messages name the generator, so a failure is still unambiguous. [TestMethod] - [DynamicData(nameof(Generators))] - public void Generator_EmitsSourcesWithTheCanonicalHeader(IIncrementalGenerator generator, string metadataFileName) + public void EveryGenerator_EmitsSourcesWithTheCanonicalHeader() { - ArgumentNullException.ThrowIfNull(generator); - - GeneratorRunResult result = RunGenerator(generator, metadataFileName); - - Assert.IsEmpty( - result.Diagnostics, - $"{generator.GetType().Name} reported diagnostics: {string.Join("; ", result.Diagnostics.Select(d => d.GetMessage()))}"); - - Assert.IsGreaterThan( - 0, - result.GeneratedSources.Length, - $"{generator.GetType().Name} produced no sources from {metadataFileName}."); - - foreach (GeneratedSourceResult source in result.GeneratedSources) + foreach ((IIncrementalGenerator generator, string metadataFileName) in Generators) { - // Normalise newlines so the assertion does not depend on the host platform. - string text = source.SourceText.ToString().Replace("\r\n", "\n", StringComparison.Ordinal); - - Assert.StartsWith( - ExpectedHeader, - text, - $"{generator.GetType().Name} emitted {source.HintName} without the expected file header. " + - $"GeneratorBase.WriteHeaderTo must stay in step with file_header_template in .editorconfig, " + - $"otherwise every generated file fails IDE0073."); + string generatorName = generator.GetType().Name; + GeneratorRunResult result = RunGenerator(generator, metadataFileName); + + Assert.IsEmpty( + result.Diagnostics, + $"{generatorName} reported diagnostics: {string.Join("; ", result.Diagnostics.Select(d => d.GetMessage()))}"); + + Assert.IsGreaterThan( + 0, + result.GeneratedSources.Length, + $"{generatorName} produced no sources from {metadataFileName}."); + + foreach (GeneratedSourceResult source in result.GeneratedSources) + { + // Normalise newlines so the assertion does not depend on the host platform. + string text = source.SourceText.ToString().Replace("\r\n", "\n", StringComparison.Ordinal); + + Assert.StartsWith( + ExpectedHeader, + text, + $"{generatorName} emitted {source.HintName} without the expected file header. " + + $"GeneratorBase.WriteHeaderTo must stay in step with file_header_template in .editorconfig, " + + $"otherwise every generated file fails IDE0073."); + } } } diff --git a/Semantics.Test/Semantics.Test.csproj b/Semantics.Test/Semantics.Test.csproj index b5e1e232..71f4abe7 100644 --- a/Semantics.Test/Semantics.Test.csproj +++ b/Semantics.Test/Semantics.Test.csproj @@ -32,9 +32,9 @@ - + FileNotFoundException without CodeBlocker present. PropertyTemplateTests also needs its + types at compile time. --> +