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