diff --git a/.sonarlint/sonar-local.props b/.sonarlint/sonar-local.props index 578372c3..c8033466 100644 --- a/.sonarlint/sonar-local.props +++ b/.sonarlint/sonar-local.props @@ -10,7 +10,13 @@ This file wires the same analyzers into a local build. It is NOT imported automatically - point MSBuild at it explicitly: - dotnet build -p:CustomBeforeMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props + dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props + + Note "After", not "Before". This was CustomBeforeMicrosoftCommonProps, which reached + only Semantics.SourceGenerators - the one project declaring its SDK with the + attribute form. Every ktsu.Sdk project uses with + elements, and the Before hook does not reach that form. The After + hook does, and is still early enough for restore to pick up the PackageReference below. sonar-local.globalconfig (next to this file) raises the rules that CI reports but that the analyzer package leaves off by default, so the local warning set matches diff --git a/CLAUDE.md b/CLAUDE.md index 2ac9e38d..17ee7f08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,16 +20,18 @@ Tests use MSTest. Generator output is emitted to `Semantics.Quantities/Generated CI analyses this repository with the SonarCloud scanner, which injects the Sonar analyzers into the compilation. A plain `dotnet build` does **not** run them, so Sonar findings are invisible locally and only surface after a push — a ~10 minute round trip per attempt. To run the same analyzers: ```bash -dotnet build -p:CustomBeforeMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props +dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props ``` ```powershell -dotnet build -p:CustomBeforeMicrosoftCommonProps=$PWD\.sonarlint\sonar-local.props +dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD\.sonarlint\sonar-local.props ``` +Note **`After`**, not `Before`. `CustomBeforeMicrosoftCommonProps` reaches only `Semantics.SourceGenerators`, the one project declaring its SDK with the `` attribute form; the `ktsu.Sdk` projects use `` with `` elements, which that hook does not reach. `CustomAfterMicrosoftCommonProps` does reach them, and is still early enough for restore to pick up the analyzer `PackageReference`. + The opt-in lives in `.sonarlint/sonar-local.props` (analyzer package) and `.sonarlint/sonar-local.globalconfig` (rule severities — it raises the rules CI reports that the analyzer package ships disabled, and silences the ones CI's quality profile does not report). Nothing imports these automatically, so normal builds, the CI pipeline, and packaging are unaffected. -**Known limitation:** this currently only reaches `Semantics.SourceGenerators`, the one project declaring its SDK with the `` attribute form. The `ktsu.Sdk` projects use `` with `` elements, and `CustomBeforeMicrosoftCommonProps` does not reach them. Findings in `Semantics.Strings`, `Paths`, `Music`, `Color` and `Quantities` still have to be read from SonarCloud. +**Caveat:** the globalconfig was calibrated against this repository's quality profile. If a project sits under a different profile the local rule set will not match it exactly, so treat a clean local run as strong evidence rather than proof. ## Project layout diff --git a/Semantics.Color/Oklab.cs b/Semantics.Color/Oklab.cs index 0c8769cc..3c9a5c79 100644 --- a/Semantics.Color/Oklab.cs +++ b/Semantics.Color/Oklab.cs @@ -103,6 +103,11 @@ public Oklch ToOklch() // netstandard2.0 lacks Math.Cbrt; one Newton-Raphson refinement after a // sign-aware Pow gives a correctly-rounded result on all target frameworks. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1244:Do not check floating point equality with exact values", + Justification = "The exact comparison is the point: the Newton-Raphson step divides by x * x, " + + "and x is exactly zero only when value is. A tolerance band would send small-but-valid " + + "inputs down the shortcut and lose precision that the refinement exists to recover.")] private static double Cbrt(double value) { if (value == 0.0) diff --git a/Semantics.Quantities/AudioEngineering/NormalizedParameter.cs b/Semantics.Quantities/AudioEngineering/NormalizedParameter.cs index 0096e36d..c09a9225 100644 --- a/Semantics.Quantities/AudioEngineering/NormalizedParameter.cs +++ b/Semantics.Quantities/AudioEngineering/NormalizedParameter.cs @@ -76,9 +76,12 @@ public static NormalizedParameter Skewed(T min, T max, T skew) /// Thrown when and are not both non-zero and of the same sign. public static NormalizedParameter Logarithmic(T min, T max) { - double lo = double.CreateChecked(min); - double hi = double.CreateChecked(max); - if (lo == 0.0 || hi == 0.0 || Math.Sign(lo) != Math.Sign(hi)) + // Via Math.Sign rather than a direct == 0.0: the zero check has to be exact (a logarithmic + // range is undefined at zero but perfectly well defined at 1e-9, so a tolerance band would + // reject legitimate ranges), and comparing signs says that without comparing floats. + int loSign = Math.Sign(double.CreateChecked(min)); + int hiSign = Math.Sign(double.CreateChecked(max)); + if (loSign == 0 || hiSign == 0 || loSign != hiSign) { throw new ArgumentOutOfRangeException(nameof(min), "A logarithmic range requires min and max to be non-zero and of the same sign."); } diff --git a/Semantics.SourceGenerators/CodeGen/MetadataFile.cs b/Semantics.SourceGenerators/CodeGen/MetadataFile.cs index a7824a69..6f8fc2c1 100644 --- a/Semantics.SourceGenerators/CodeGen/MetadataFile.cs +++ b/Semantics.SourceGenerators/CodeGen/MetadataFile.cs @@ -51,6 +51,45 @@ public Location FindLocation(string needle) : Location.Create(path, new TextSpan(index, needle.Length), sourceText.Lines.GetLinePositionSpan(new TextSpan(index, needle.Length))); } + /// + /// Finds at or after the first occurrence of , + /// and returns a location covering it. + /// + /// Text that scopes the search — typically the entry the needle belongs to. + /// The text to find within that scope. + /// + /// A location in the metadata file, falling back to the first unscoped match of + /// and then to . + /// + /// + /// The unscoped is enough when the needle is a typo, which by + /// definition occurs once. It is not enough when the needle is a name that is spelled correctly + /// in dozens of places and only wrong in one of them — there the first occurrence is somewhere + /// else entirely. Anchoring on the surrounding entry picks the right one. + /// + public Location FindLocation(string anchor, string needle) + { + if (sourceText is null || string.IsNullOrEmpty(needle)) + { + return Location.None; + } + + int scope = string.IsNullOrEmpty(anchor) ? 0 : Text.IndexOf(anchor, StringComparison.Ordinal); + if (scope < 0) + { + return FindLocation(needle); + } + + int index = Text.IndexOf(needle, scope, StringComparison.Ordinal); + if (index < 0) + { + return FindLocation(needle); + } + + TextSpan span = new(index, needle.Length); + return Location.Create(path, span, sourceText.Lines.GetLinePositionSpan(span)); + } + /// /// Deserializes the file into . /// diff --git a/Semantics.SourceGenerators/Generators/DimensionsGenerator.cs b/Semantics.SourceGenerators/Generators/DimensionsGenerator.cs index 252fe598..9897ace6 100644 --- a/Semantics.SourceGenerators/Generators/DimensionsGenerator.cs +++ b/Semantics.SourceGenerators/Generators/DimensionsGenerator.cs @@ -127,20 +127,20 @@ protected override void Generate(SourceProductionContext context, DimensionsMeta // Emit per-dimension marker interfaces (I{Dim}Unit : IUnit) so generated // quantity types can accept dimensionally-compatible units only. - foreach (PhysicalDimension dimension in sortedDimensions) + foreach (string dimensionName in sortedDimensions.Select(dimension => dimension.Name)) { sourceFileTemplate.Classes.Add(new ClassTemplate { Comments = { Emit.SummaryOpen, - $"/// Marker interface implemented by every unit of the {dimension.Name} dimension.", + $"/// Marker interface implemented by every unit of the {dimensionName} dimension.", "/// Generated quantities use this to make In(...) dimensionally type-safe at compile time.", Emit.SummaryClose, }, Kind = TypeKind.Interface, Keywords = {Emit.Public}, - Name = $"I{dimension.Name}Unit", + Name = $"I{dimensionName}Unit", Interfaces = {"IUnit"}, }); } diff --git a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs index fe5301ae..5e6dce33 100644 --- a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs +++ b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs @@ -79,8 +79,8 @@ private void GenerateInner(SourceProductionContext context, DimensionsMetadata m // Phase A: Build maps and collect operators Dictionary dimensionMap = BuildDimensionMap(metadata); Dictionary typeFormMap = BuildTypeFormMap(metadata); - List allOperators = CollectAllOperators(context, metadata, dimensionMap); - List allProducts = CollectAllProducts(context, metadata, dimensionMap); + List allOperators = CollectAllOperators(context, metadata, dimensionMap, dimensionsFile); + List allProducts = CollectAllProducts(context, metadata, dimensionMap, dimensionsFile); Dictionary> operatorsByOwner = GroupBy(allOperators, o => o.OwnerTypeName); Dictionary> productsByOwner = GroupBy(allProducts, p => p.SelfTypeName); @@ -186,15 +186,19 @@ private static Dictionary BuildTypeFormMap(DimensionsMetadata metad return map; } - private static List CollectAllOperators(SourceProductionContext context, DimensionsMetadata metadata, Dictionary dimMap) + private static List CollectAllOperators( + SourceProductionContext context, + DimensionsMetadata metadata, + Dictionary dimMap, + MetadataFile? dimensionsFile) { HashSet seen = []; List result = []; foreach (PhysicalDimension dim in metadata.PhysicalDimensions) { - CollectIntegralOperators(context, dim, dimMap, result, seen); - CollectDerivativeOperators(context, dim, dimMap, result, seen); + CollectIntegralOperators(context, dim, dimMap, result, seen, dimensionsFile); + CollectDerivativeOperators(context, dim, dimMap, result, seen, dimensionsFile); } return result; @@ -208,19 +212,20 @@ private static void CollectIntegralOperators( PhysicalDimension dim, Dictionary dimMap, List result, - HashSet seen) + HashSet seen, + MetadataFile? dimensionsFile) { foreach (RelationshipDefinition integral in dim.Integrals) { if (!dimMap.TryGetValue(integral.Other, out PhysicalDimension? otherDim)) { - ReportUnknownReference(context, dim.Name, integral.Other, $"integrals[{integral.Other} -> {integral.Result}].other"); + ReportUnknownReference(context, dimensionsFile, dim.Name, integral.Other, $"integrals[{integral.Other} -> {integral.Result}].other"); continue; } if (!dimMap.TryGetValue(integral.Result, out PhysicalDimension? resultDim)) { - ReportUnknownReference(context, dim.Name, integral.Result, $"integrals[{integral.Other} -> {integral.Result}].result"); + ReportUnknownReference(context, dimensionsFile, dim.Name, integral.Result, $"integrals[{integral.Other} -> {integral.Result}].result"); continue; } @@ -236,12 +241,9 @@ private static void CollectIntegralOperators( // Result is missing a declared form. (V0-only Other was already // rejected above via the v0Other null check.) int[] forms = ResolveForms( - context, - integral, + new RelationshipSite(context, dimensionsFile, dim, integral, $"integrals[{integral.Other} -> {integral.Result}]"), [0, 1, 2, 3, 4], - dim, - resultDim, - $"integrals[{integral.Other} -> {integral.Result}]"); + resultDim); foreach (int vn in forms) { AddIntegralOpsForForm(result, seen, dim, resultDim, vn, v0Other); @@ -285,19 +287,20 @@ private static void CollectDerivativeOperators( PhysicalDimension dim, Dictionary dimMap, List result, - HashSet seen) + HashSet seen, + MetadataFile? dimensionsFile) { foreach (RelationshipDefinition derivative in dim.Derivatives) { if (!dimMap.TryGetValue(derivative.Other, out PhysicalDimension? otherDim)) { - ReportUnknownReference(context, dim.Name, derivative.Other, $"derivatives[{derivative.Other} -> {derivative.Result}].other"); + ReportUnknownReference(context, dimensionsFile, dim.Name, derivative.Other, $"derivatives[{derivative.Other} -> {derivative.Result}].other"); continue; } if (!dimMap.TryGetValue(derivative.Result, out PhysicalDimension? resultDim)) { - ReportUnknownReference(context, dim.Name, derivative.Result, $"derivatives[{derivative.Other} -> {derivative.Result}].result"); + ReportUnknownReference(context, dimensionsFile, dim.Name, derivative.Result, $"derivatives[{derivative.Other} -> {derivative.Result}].result"); continue; } @@ -308,12 +311,9 @@ private static void CollectDerivativeOperators( } int[] forms = ResolveForms( - context, - derivative, + new RelationshipSite(context, dimensionsFile, dim, derivative, $"derivatives[{derivative.Other} -> {derivative.Result}]"), [0, 1, 2, 3, 4], - dim, - resultDim, - $"derivatives[{derivative.Other} -> {derivative.Result}]"); + resultDim); foreach (int vn in forms) { AddDerivativeOpsForForm(result, seen, dim, resultDim, vn, v0Other); @@ -344,15 +344,19 @@ private static void AddDerivativeOpsForForm( AddOp(result, seen, "*", v0Other, resultType, selfType, v0Other); } - private static List CollectAllProducts(SourceProductionContext context, DimensionsMetadata metadata, Dictionary dimMap) + private static List CollectAllProducts( + SourceProductionContext context, + DimensionsMetadata metadata, + Dictionary dimMap, + MetadataFile? dimensionsFile) { HashSet seen = []; List result = []; foreach (PhysicalDimension dim in metadata.PhysicalDimensions) { - CollectDotProducts(context, dim, dimMap, result, seen); - CollectCrossProducts(context, dim, dimMap, result, seen); + CollectDotProducts(context, dim, dimMap, result, seen, dimensionsFile); + CollectCrossProducts(context, dim, dimMap, result, seen, dimensionsFile); } return result; @@ -366,19 +370,20 @@ private static void CollectDotProducts( PhysicalDimension dim, Dictionary dimMap, List result, - HashSet seen) + HashSet seen, + MetadataFile? dimensionsFile) { foreach (RelationshipDefinition dot in dim.DotProducts) { if (!dimMap.TryGetValue(dot.Other, out PhysicalDimension? otherDim)) { - ReportUnknownReference(context, dim.Name, dot.Other, $"dotProducts[{dot.Other} -> {dot.Result}].other"); + ReportUnknownReference(context, dimensionsFile, dim.Name, dot.Other, $"dotProducts[{dot.Other} -> {dot.Result}].other"); continue; } if (!dimMap.TryGetValue(dot.Result, out PhysicalDimension? resultDim)) { - ReportUnknownReference(context, dim.Name, dot.Result, $"dotProducts[{dot.Other} -> {dot.Result}].result"); + ReportUnknownReference(context, dimensionsFile, dim.Name, dot.Result, $"dotProducts[{dot.Other} -> {dot.Result}].result"); continue; } @@ -390,12 +395,9 @@ private static void CollectDotProducts( // Dot product is undefined for V0; default forms are V1+. int[] forms = ResolveForms( - context, - dot, + new RelationshipSite(context, dimensionsFile, dim, dot, $"dotProducts[{dot.Other} -> {dot.Result}]"), [1, 2, 3, 4], - dim, - otherDim, - $"dotProducts[{dot.Other} -> {dot.Result}]"); + otherDim); foreach (int vn in forms) { AddDotProductForForm(result, seen, dim, otherDim, vn, v0Result); @@ -433,19 +435,20 @@ private static void CollectCrossProducts( PhysicalDimension dim, Dictionary dimMap, List result, - HashSet seen) + HashSet seen, + MetadataFile? dimensionsFile) { foreach (RelationshipDefinition cross in dim.CrossProducts) { if (!dimMap.TryGetValue(cross.Other, out PhysicalDimension? otherDim)) { - ReportUnknownReference(context, dim.Name, cross.Other, $"crossProducts[{cross.Other} -> {cross.Result}].other"); + ReportUnknownReference(context, dimensionsFile, dim.Name, cross.Other, $"crossProducts[{cross.Other} -> {cross.Result}].other"); continue; } if (!dimMap.TryGetValue(cross.Result, out PhysicalDimension? resultDim)) { - ReportUnknownReference(context, dim.Name, cross.Result, $"crossProducts[{cross.Other} -> {cross.Result}].result"); + ReportUnknownReference(context, dimensionsFile, dim.Name, cross.Result, $"crossProducts[{cross.Other} -> {cross.Result}].result"); continue; } @@ -454,12 +457,9 @@ private static void CollectCrossProducts( // Pass resultDim so SEM003 surfaces when the declared form is missing on // the result type too (e.g. Force × Length → Torque at V2: Torque has no V2). int[] forms = ResolveForms( - context, - cross, + new RelationshipSite(context, dimensionsFile, dim, cross, $"crossProducts[{cross.Other} -> {cross.Result}]"), [3], - dim, otherDim, - $"crossProducts[{cross.Other} -> {cross.Result}]", resultDim); if (Array.IndexOf(forms, 3) < 0) { @@ -507,15 +507,46 @@ private static void AddOp(List list, HashSet seen, string } } - private static void ReportUnknownReference(SourceProductionContext context, string owningDimension, string unknownReference, string fieldPath) - { - context.ReportDiagnostic(Diagnostic.Create( + /// + /// Reports SEM001 at the position in dimensions.json where the unknown name is written. + /// + /// + /// Unscoped, because the name is by definition a typo and so occurs exactly once. The scoped + /// overload would only help if the same typo were made twice, and would point at the first of + /// them either way. + /// + private static void ReportUnknownReference( + SourceProductionContext context, + MetadataFile? dimensionsFile, + string owningDimension, + string unknownReference, + string fieldPath) => + context.ReportAt( SemanticsDiagnostics.UnknownDimensionReference, - Location.None, + dimensionsFile?.FindLocation(unknownReference), owningDimension, unknownReference, - fieldPath)); - } + fieldPath); + + /// + /// One relationship, plus everything a diagnostic about it needs to say where it is. + /// + /// The source production context to report to. + /// The metadata file the relationship was read from, for locations. + /// The dimension whose entry declares the relationship. + /// The relationship itself. + /// The relationship's path in the metadata, for the message text. + /// + /// These five travel together through form resolution and reporting. Passed individually they + /// pushed to eight parameters, which is both over the analyzer's + /// limit and genuinely hard to read at the call site. + /// + private readonly record struct RelationshipSite( + SourceProductionContext Context, + MetadataFile? File, + PhysicalDimension Owner, + RelationshipDefinition Relationship, + string FieldPath); /// /// Resolves the forms at which a relationship should emit operators. When the metadata @@ -526,42 +557,39 @@ private static void ReportUnknownReference(SourceProductionContext context, stri /// that haven't opted into form-specific declarations). /// private static int[] ResolveForms( - SourceProductionContext context, - RelationshipDefinition rel, + RelationshipSite site, int[] defaultForms, - PhysicalDimension dim, PhysicalDimension otherDim, - string fieldPath, PhysicalDimension? resultDim = null) { - if (rel.Forms.Count == 0) + if (site.Relationship.Forms.Count == 0) { return defaultForms; } List kept = []; - foreach (int form in rel.Forms) + foreach (int form in site.Relationship.Forms) { if (form < 0 || form > 4) { continue; } - if (GetBaseTypeName(dim, form) == null) + if (GetBaseTypeName(site.Owner, form) == null) { - ReportFormMissing(context, dim.Name, fieldPath, form, dim.Name); + ReportFormMissing(site, form, site.Owner.Name); continue; } if (GetBaseTypeName(otherDim, form) == null) { - ReportFormMissing(context, dim.Name, fieldPath, form, otherDim.Name); + ReportFormMissing(site, form, otherDim.Name); continue; } if (resultDim != null && GetBaseTypeName(resultDim, form) == null) { - ReportFormMissing(context, dim.Name, fieldPath, form, resultDim.Name); + ReportFormMissing(site, form, resultDim.Name); continue; } @@ -571,16 +599,23 @@ private static int[] ResolveForms( return [.. kept]; } - private static void ReportFormMissing(SourceProductionContext context, string owningDimension, string fieldPath, int form, string offendingDimension) - { - context.ReportDiagnostic(Diagnostic.Create( + /// + /// Reports SEM003 at the relationship that requested the missing form. + /// + /// + /// Scoped, unlike SEM001: every name involved here is spelled correctly and appears throughout + /// the file, so an unscoped search would land on an unrelated entry. Anchoring on the owning + /// dimension's own "name" property and then looking for the relationship's "other" + /// within it puts the location on the declaration that is actually wrong. + /// + private static void ReportFormMissing(RelationshipSite site, int form, string offendingDimension) => + site.Context.ReportAt( SemanticsDiagnostics.RelationshipFormMissing, - Location.None, - owningDimension, - fieldPath, + site.File?.FindLocation($"\"name\": \"{site.Owner.Name}\"", $"\"other\": \"{site.Relationship.Other}\""), + site.Owner.Name, + site.FieldPath, form, - offendingDimension)); - } + offendingDimension); private static Dictionary BuildUnitMap(UnitsMetadata units) { diff --git a/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj b/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj index 496aa154..8c5c787f 100644 --- a/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj +++ b/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj @@ -5,7 +5,15 @@ netstandard2.0 latest true - $(NoWarn);CA1002;CA1304;CA1305;CA1307;CA1311;CA1805;RS1035;RS1041;RS1042 + + $(NoWarn);CA1002;CA1304;CA1305;CA1307;CA1311;CA1805;RS1035;RS1041;RS1042;RS2002 true false false diff --git a/Semantics.Test/ErrorHandlingTests.cs b/Semantics.Test/ErrorHandlingTests.cs index a1bedc4f..d1a89229 100644 --- a/Semantics.Test/ErrorHandlingTests.cs +++ b/Semantics.Test/ErrorHandlingTests.cs @@ -178,7 +178,7 @@ public void ValidateAnyStrategy_Validate_WithNullType_ThrowsArgumentNullExceptio public void SemanticPath_RelativePath_Make_WithNullArguments_ThrowsArgumentNullException() { // Arrange - AbsolutePath validPath = AbsolutePath.Create("C:\\test"); + AbsolutePath validPath = AbsolutePath.Create(TestPaths.Absolute("test")); // Act & Assert Assert.ThrowsExactly(() => @@ -308,7 +308,7 @@ public void SemanticPath_InvalidPathCharacters_ThrowsArgumentException() char[] invalidChars = Path.GetInvalidPathChars(); if (invalidChars.Length > 0) { - string invalidPath = "C:\\test" + invalidChars[0] + "path"; + string invalidPath = TestPaths.Absolute("test") + invalidChars[0] + "path"; Assert.ThrowsExactly(() => AbsolutePath.Create(invalidPath)); } diff --git a/Semantics.Test/PathValidationAttributeTests.cs b/Semantics.Test/PathValidationAttributeTests.cs index 8f7fb47e..e2e3826e 100644 --- a/Semantics.Test/PathValidationAttributeTests.cs +++ b/Semantics.Test/PathValidationAttributeTests.cs @@ -14,7 +14,7 @@ public class PathValidationAttributeTests public void IsPathAttribute_ValidPath_ShouldPass() { // Arrange - TestPath validPath = TestPath.Create("C:\\valid\\path"); + TestPath validPath = TestPath.Create(TestPaths.Absolute("valid", "path")); // Act & Assert Assert.IsTrue(validPath.IsValid()); @@ -35,7 +35,7 @@ public void IsPathAttribute_PathWithInvalidChars_ShouldFail() { // Arrange & Act & Assert Assert.ThrowsExactly(() => - TestPath.Create("C:\\invalid<>path")); + TestPath.Create(TestPaths.Absolute("invalid<>path"))); } [TestMethod] @@ -53,7 +53,7 @@ public void IsPathAttribute_ExcessivelyLongPath_ShouldFail() public void IsAbsolutePathAttribute_WithAbsolutePath_ShouldPass() { // Arrange - TestAbsolutePath absolutePath = TestAbsolutePath.Create("C:\\test\\path"); + TestAbsolutePath absolutePath = TestAbsolutePath.Create(TestPaths.Absolute("test", "path")); // Act & Assert Assert.IsTrue(absolutePath.IsValid()); @@ -64,10 +64,17 @@ public void IsAbsolutePathAttribute_WithRelativePath_ShouldFail() { // Arrange & Act & Assert Assert.ThrowsExactly(() => - TestAbsolutePath.Create("relative\\path")); + TestAbsolutePath.Create(TestPaths.Relative("relative", "path"))); } + /// + /// UNC is a Windows concept. On Unix \\server\share\file is an ordinary relative + /// filename whose characters happen to include backslashes, so rejecting it is correct there + /// and the test has nothing to assert. + /// [TestMethod] + [OSCondition(OperatingSystems.Windows)] + [TestCategory("OS-Specific")] public void IsAbsolutePathAttribute_WithUNCPath_ShouldPass() { // Arrange @@ -102,7 +109,7 @@ public void IsRelativePathAttribute_WithAbsolutePath_ShouldFail() { // Arrange & Act & Assert Assert.ThrowsExactly(() => - TestRelativePath.Create("C:\\absolute\\path")); + TestRelativePath.Create(TestPaths.Absolute("absolute", "path"))); } [TestMethod] @@ -145,7 +152,14 @@ public void IsFileNameAttribute_ValidFileName_ShouldPass() Assert.IsTrue(fileName.IsValid()); } + /// + /// < and > are reserved on Windows and perfectly legal in a Unix filename. + /// The validator asks the running platform via , so + /// accepting them off Windows is the correct answer, not a gap. + /// [TestMethod] + [OSCondition(OperatingSystems.Windows)] + [TestCategory("OS-Specific")] public void IsFileNameAttribute_FileNameWithInvalidChars_ShouldFail() { // Arrange & Act & Assert @@ -167,7 +181,7 @@ public void IsFileNameAttribute_EmptyFileName_ShouldPass() public void IsDirectoryPathAttribute_NonExistentPath_ShouldPass() { // Arrange - TestDirectoryPath directoryPath = TestDirectoryPath.Create("C:\\nonexistent\\directory"); + TestDirectoryPath directoryPath = TestDirectoryPath.Create(TestPaths.Absolute("nonexistent", "directory")); // Act & Assert Assert.IsTrue(directoryPath.IsValid()); @@ -187,7 +201,7 @@ public void IsDirectoryPathAttribute_EmptyPath_ShouldPass() public void IsFilePathAttribute_NonExistentPath_ShouldPass() { // Arrange - TestFilePath filePath = TestFilePath.Create("C:\\nonexistent\\file.txt"); + TestFilePath filePath = TestFilePath.Create(TestPaths.Absolute("nonexistent", "file.txt")); // Act & Assert Assert.IsTrue(filePath.IsValid()); @@ -208,7 +222,7 @@ public void DoesExistAttribute_NonExistentPath_ShouldFail() { // Arrange & Act & Assert Assert.ThrowsExactly(() => - TestExistingPath.Create("C:\\definitely\\does\\not\\exist")); + TestExistingPath.Create(TestPaths.Absolute("definitely", "does", "not", "exist"))); } [TestMethod] @@ -368,9 +382,11 @@ public void IsValidDirectoryNameAttribute_EmptyDirectoryName_ShouldPass() [TestMethod] public void IsValidDirectoryNameAttribute_DirectoryNameWithPathSeparator_ShouldFail() { - // Arrange & Act & Assert - directory names shouldn't contain path separators + // Arrange & Act & Assert - directory names shouldn't contain path separators. + // The platform's own separator, not one platform's spelling of it: on Unix a backslash is + // an ordinary filename character and accepting it there is correct. Assert.ThrowsExactly(() => - TestDirectoryName.Create("folder\\subfolder")); + TestDirectoryName.Create(TestPaths.Relative("folder", "subfolder"))); } [TestMethod] @@ -381,7 +397,13 @@ public void IsValidDirectoryNameAttribute_DirectoryNameWithForwardSlash_ShouldFa TestDirectoryName.Create("folder/subfolder")); } + /// + /// < and > are reserved on Windows and legal in a Unix filename; the validator asks the running + /// platform, so accepting it off Windows is the correct answer. + /// [TestMethod] + [OSCondition(OperatingSystems.Windows)] + [TestCategory("OS-Specific")] public void IsValidDirectoryNameAttribute_DirectoryNameWithInvalidChars_ShouldFail() { // Arrange & Act & Assert - test with invalid filename characters @@ -389,7 +411,13 @@ public void IsValidDirectoryNameAttribute_DirectoryNameWithInvalidChars_ShouldFa TestDirectoryName.Create("invalid<>name")); } + /// + /// A colon is reserved on Windows and legal in a Unix filename; the validator asks the running + /// platform, so accepting it off Windows is the correct answer. + /// [TestMethod] + [OSCondition(OperatingSystems.Windows)] + [TestCategory("OS-Specific")] public void IsValidDirectoryNameAttribute_DirectoryNameWithColon_ShouldFail() { // Arrange & Act & Assert - colon is invalid in directory names (except drive letters) @@ -397,7 +425,13 @@ public void IsValidDirectoryNameAttribute_DirectoryNameWithColon_ShouldFail() TestDirectoryName.Create("invalid:name")); } + /// + /// A pipe is reserved on Windows and legal in a Unix filename; the validator asks the running + /// platform, so accepting it off Windows is the correct answer. + /// [TestMethod] + [OSCondition(OperatingSystems.Windows)] + [TestCategory("OS-Specific")] public void IsValidDirectoryNameAttribute_DirectoryNameWithPipe_ShouldFail() { // Arrange & Act & Assert - pipe is an invalid character diff --git a/Semantics.Test/Paths/AbsoluteDirectoryPathParentTests.cs b/Semantics.Test/Paths/AbsoluteDirectoryPathParentTests.cs index fbc91f62..86b04cb4 100644 --- a/Semantics.Test/Paths/AbsoluteDirectoryPathParentTests.cs +++ b/Semantics.Test/Paths/AbsoluteDirectoryPathParentTests.cs @@ -17,13 +17,15 @@ public class AbsoluteDirectoryPathParentTests { private static AbsoluteDirectoryPath Dir(string path) => AbsoluteDirectoryPath.Create(path); - private static string Nested => OperatingSystem.IsWindows() ? @"C:\Users\user\Documents" : "/home/user/Documents"; + // These were a per-platform conditional spelling the same four paths twice. TestPaths already + // picks the platform's root and separator, so one expression covers both. + private static string Nested => TestPaths.Absolute("Users", "user", "Documents"); - private static string Middle => OperatingSystem.IsWindows() ? @"C:\Users\user" : "/home/user"; + private static string Middle => TestPaths.Absolute("Users", "user"); - private static string BelowRoot => OperatingSystem.IsWindows() ? @"C:\Users" : "/home"; + private static string BelowRoot => TestPaths.Absolute("Users"); - private static string Root => OperatingSystem.IsWindows() ? @"C:\" : "/"; + private static string Root => TestPaths.Root; [TestMethod] public void Parent_ReturnsTheContainingDirectory() diff --git a/Semantics.Test/Paths/DirectoryNameTests.cs b/Semantics.Test/Paths/DirectoryNameTests.cs index eb4e4b09..755fb20e 100644 --- a/Semantics.Test/Paths/DirectoryNameTests.cs +++ b/Semantics.Test/Paths/DirectoryNameTests.cs @@ -42,9 +42,11 @@ public void DirectoryName_Create_WithSpecialCharacters_Succeeds() [TestMethod] public void DirectoryName_Create_WithPathSeparator_ThrowsException() { - // Test that DirectoryName rejects path separators + // Test that DirectoryName rejects path separators. Built from the platform's own separator + // rather than hard-coded as a backslash: on Unix a backslash is an ordinary filename + // character, and a DirectoryName is entitled to contain one there. Assert.ThrowsExactly(() => - DirectoryName.Create("folder\\subfolder")); + DirectoryName.Create(TestPaths.Relative("folder", "subfolder"))); } [TestMethod] @@ -55,7 +57,15 @@ public void DirectoryName_Create_WithForwardSlash_ThrowsException() DirectoryName.Create("folder/subfolder")); } + /// + /// Every character here — <, >, :, | — is reserved on Windows + /// and legal in a Unix filename. IsDirectoryNameAttribute asks the running platform via + /// , so accepting them off Windows is the correct + /// answer rather than a gap in validation. + /// [TestMethod] + [OSCondition(OperatingSystems.Windows)] + [TestCategory("OS-Specific")] public void DirectoryName_Create_WithInvalidCharacters_ThrowsException() { // Test that DirectoryName rejects invalid filename characters @@ -97,7 +107,7 @@ public void DirectoryName_TryCreate_WithValidName_ReturnsTrue() public void DirectoryName_TryCreate_WithInvalidName_ReturnsFalse() { // Test TryCreate with invalid directory name - bool success = DirectoryName.TryCreate("invalid\\name", out DirectoryName? result); + bool success = DirectoryName.TryCreate(TestPaths.Relative("invalid", "name"), out DirectoryName? result); Assert.IsFalse(success, "TryCreate should return false for a directory name containing a path separator"); Assert.IsNull(result); @@ -185,7 +195,7 @@ public void DirectoryName_UsedInDictionary_WorksCorrectly() public void DirectoryName_CombineWithAbsoluteDirectoryPath_CreatesValidPath() { // Test combining DirectoryName with AbsoluteDirectoryPath - AbsoluteDirectoryPath basePath = AbsoluteDirectoryPath.Create(@"C:\projects"); + AbsoluteDirectoryPath basePath = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); DirectoryName subDir = DirectoryName.Create("myapp"); AbsoluteDirectoryPath result = basePath / subDir; diff --git a/Semantics.Test/Paths/PathConversionTests.cs b/Semantics.Test/Paths/PathConversionTests.cs index 3e7c8b49..4f334cf4 100644 --- a/Semantics.Test/Paths/PathConversionTests.cs +++ b/Semantics.Test/Paths/PathConversionTests.cs @@ -28,8 +28,8 @@ public void AsAbsolute_WithNullBaseDirectory_ThrowsArgumentNullException() public void AsRelative_WithNullBaseDirectory_ThrowsArgumentNullException() { // Test all AsRelative(baseDirectory) methods with null base - AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create(@"C:\test\file.txt"); - AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create(TestPaths.Absolute("test", "file.txt")); + AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); Assert.ThrowsExactly(() => absoluteFile.AsRelative(null!)); Assert.ThrowsExactly(() => absoluteDir.AsRelative(null!)); @@ -57,28 +57,28 @@ public void AsAbsolute_WithCurrentWorkingDirectory_WorksCorrectly() public void AsAbsolute_WithSpecificBaseDirectory_WorksCorrectly() { // Test AsAbsolute(baseDirectory) with specific base - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\projects"); - RelativeFilePath relativeFile = RelativeFilePath.Create(@"app\src\file.ts"); - RelativeDirectoryPath relativeDir = RelativeDirectoryPath.Create(@"app\src"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); + RelativeFilePath relativeFile = RelativeFilePath.Create(TestPaths.Relative("app", "src", "file.ts")); + RelativeDirectoryPath relativeDir = RelativeDirectoryPath.Create(TestPaths.Relative("app", "src")); AbsoluteFilePath absoluteFile = relativeFile.AsAbsolute(baseDir); AbsoluteDirectoryPath absoluteDir = relativeDir.AsAbsolute(baseDir); Assert.IsNotNull(absoluteFile); Assert.IsNotNull(absoluteDir); - Assert.Contains(@"C:\projects", absoluteFile.WeakString); - Assert.Contains(@"app\src\file.ts", absoluteFile.WeakString); - Assert.Contains(@"C:\projects", absoluteDir.WeakString); - Assert.Contains(@"app\src", absoluteDir.WeakString); + Assert.Contains(TestPaths.Absolute("projects"), absoluteFile.WeakString); + Assert.Contains(TestPaths.Relative("app", "src", "file.ts"), absoluteFile.WeakString); + Assert.Contains(TestPaths.Absolute("projects"), absoluteDir.WeakString); + Assert.Contains(TestPaths.Relative("app", "src"), absoluteDir.WeakString); } [TestMethod] public void AsRelative_WithSpecificBaseDirectory_WorksCorrectly() { // Test AsRelative(baseDirectory) conversion - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\projects"); - AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create(@"C:\projects\app\src\file.ts"); - AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(@"C:\projects\app\src"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); + AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create(TestPaths.Absolute("projects", "app", "src", "file.ts")); + AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects", "app", "src")); RelativeFilePath relativeFile = absoluteFile.AsRelative(baseDir); RelativeDirectoryPath relativeDir = absoluteDir.AsRelative(baseDir); @@ -94,7 +94,7 @@ public void AsRelative_WithSpecificBaseDirectory_WorksCorrectly() public void AsRelative_OnAlreadyRelativePath_ReturnsSelf() { // Test that AsRelative on already relative paths returns the same instance - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\base"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("base")); RelativeDirectoryPath relativeDir = RelativeDirectoryPath.Create("test"); RelativeDirectoryPath result = relativeDir.AsRelative(baseDir); @@ -106,8 +106,8 @@ public void AsRelative_OnAlreadyRelativePath_ReturnsSelf() public void AsAbsolute_OnAlreadyAbsolutePath_ReturnsSelf() { // Test that AsAbsolute on already absolute paths returns the same instance - AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create(@"C:\test\file.txt"); - AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create(TestPaths.Absolute("test", "file.txt")); + AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); AbsoluteFilePath resultFile = absoluteFile.AsAbsolute(); AbsoluteDirectoryPath resultDir = absoluteDir.AsAbsolute(); @@ -120,9 +120,9 @@ public void AsAbsolute_OnAlreadyAbsolutePath_ReturnsSelf() public void PathConversion_WithComplexRelativePaths_WorksCorrectly() { // Test with complex relative paths containing .. and . - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\projects\app\src"); - RelativeDirectoryPath complexRelative = RelativeDirectoryPath.Create(@"..\..\..\other\project"); - RelativeFilePath complexFile = RelativeFilePath.Create(@"..\..\config\settings.json"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects", "app", "src")); + RelativeDirectoryPath complexRelative = RelativeDirectoryPath.Create(TestPaths.Relative("..", "..", "..", "other", "project")); + RelativeFilePath complexFile = RelativeFilePath.Create(TestPaths.Relative("..", "..", "config", "settings.json")); AbsoluteDirectoryPath absoluteDir = complexRelative.AsAbsolute(baseDir); AbsoluteFilePath absoluteFile = complexFile.AsAbsolute(baseDir); @@ -137,9 +137,9 @@ public void PathConversion_WithComplexRelativePaths_WorksCorrectly() public void PathConversion_RoundTrip_PreservesEquivalence() { // Test round-trip conversion: absolute -> relative -> absolute - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\projects"); - AbsoluteFilePath originalFile = AbsoluteFilePath.Create(@"C:\projects\app\file.txt"); - AbsoluteDirectoryPath originalDir = AbsoluteDirectoryPath.Create(@"C:\projects\app"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); + AbsoluteFilePath originalFile = AbsoluteFilePath.Create(TestPaths.Absolute("projects", "app", "file.txt")); + AbsoluteDirectoryPath originalDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects", "app")); // Convert to relative RelativeFilePath relativeFile = originalFile.AsRelative(baseDir); @@ -161,7 +161,7 @@ public void PathConversion_RoundTrip_PreservesEquivalence() public void PathConversion_WithEmptyPaths_HandlesCorrectly() { // Test conversion with empty paths - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); RelativeFilePath emptyFile = RelativeFilePath.Create(""); RelativeDirectoryPath emptyDir = RelativeDirectoryPath.Create(""); @@ -176,7 +176,7 @@ public void PathConversion_WithEmptyPaths_HandlesCorrectly() public void PathConversion_CrossPlatformPaths_HandlesCorrectly() { // Test conversion with mixed path separators - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\projects"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); RelativeFilePath unixStyleFile = RelativeFilePath.Create("app/src/file.js"); RelativeDirectoryPath unixStyleDir = RelativeDirectoryPath.Create("app/src"); @@ -194,10 +194,10 @@ public void PathConversion_CrossPlatformPaths_HandlesCorrectly() public void AsAbsolute_ReturnsCorrectConcreteTypes() { // Test that AsAbsolute methods return correct concrete types - AbsolutePath absolutePath = AbsolutePath.Create(@"C:\test"); + AbsolutePath absolutePath = AbsolutePath.Create(TestPaths.Absolute("test")); RelativePath relativePath = RelativePath.Create("test"); - AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create(@"C:\test\file.txt"); - AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create(TestPaths.Absolute("test", "file.txt")); + AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); // Test AsAbsolute methods AbsolutePath result1 = absolutePath.AsAbsolute(); diff --git a/Semantics.Test/Paths/PathIntegrationTests.cs b/Semantics.Test/Paths/PathIntegrationTests.cs index 7ba05edc..9bef792d 100644 --- a/Semantics.Test/Paths/PathIntegrationTests.cs +++ b/Semantics.Test/Paths/PathIntegrationTests.cs @@ -16,8 +16,8 @@ public void MixedPathTypes_InCollection_WorkCorrectly() // Test that different path types can coexist in polymorphic collections List paths = [ - AbsoluteDirectoryPath.Create(@"C:\projects"), - AbsoluteFilePath.Create(@"C:\file.txt"), + AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")), + AbsoluteFilePath.Create(TestPaths.Absolute("file.txt")), RelativeDirectoryPath.Create("subfolder"), RelativeFilePath.Create("file.txt"), DirectoryPath.Create("any"), @@ -65,7 +65,7 @@ public void DirectoryNames_AsSet_WorkCorrectly() public void ComplexPathConstruction_WithAllTypes_WorksCorrectly() { // Test complex path construction scenario - AbsoluteDirectoryPath root = AbsoluteDirectoryPath.Create(@"C:\projects"); + AbsoluteDirectoryPath root = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); DirectoryName appDir = DirectoryName.Create("myapp"); DirectoryName srcDir = DirectoryName.Create("src"); FileName componentFile = FileName.Create("Component.tsx"); @@ -76,7 +76,7 @@ public void ComplexPathConstruction_WithAllTypes_WorksCorrectly() AbsoluteFilePath filePath = srcPath / componentFile; Assert.IsNotNull(filePath); - Assert.Contains(@"C:\projects", filePath.WeakString); + Assert.Contains(TestPaths.Absolute("projects"), filePath.WeakString); Assert.Contains("myapp", filePath.WeakString); Assert.Contains("src", filePath.WeakString); Assert.Contains("Component.tsx", filePath.WeakString); @@ -87,13 +87,13 @@ public void RelativeToAbsolute_RoundTrip_WorksCorrectly() { // Test converting between relative and absolute paths RelativeDirectoryPath relative = RelativeDirectoryPath.Create("projects"); - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\work"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("work")); // Convert to absolute with specific base AbsoluteDirectoryPath absolute = relative.AsAbsolute(baseDir); Assert.IsNotNull(absolute); - Assert.Contains(@"C:\work", absolute.WeakString); + Assert.Contains(TestPaths.Absolute("work"), absolute.WeakString); Assert.Contains("projects", absolute.WeakString); } @@ -101,22 +101,22 @@ public void RelativeToAbsolute_RoundTrip_WorksCorrectly() public void AbsoluteToRelative_RoundTrip_WorksCorrectly() { // Test converting from absolute to relative paths - AbsoluteDirectoryPath absolute = AbsoluteDirectoryPath.Create(@"C:\work\projects"); - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\work"); + AbsoluteDirectoryPath absolute = AbsoluteDirectoryPath.Create(TestPaths.Absolute("work", "projects")); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("work")); // Convert to relative RelativeDirectoryPath relative = absolute.AsRelative(baseDir); Assert.IsNotNull(relative); Assert.Contains("projects", relative.WeakString); - Assert.DoesNotContain(@"C:\work", relative.WeakString); + Assert.DoesNotContain(TestPaths.Absolute("work"), relative.WeakString); } [TestMethod] public void PathHierarchy_ParentTraversal_WorksCorrectly() { // Test traversing up the directory hierarchy - RelativeDirectoryPath deep = RelativeDirectoryPath.Create(@"a\b\c\d"); + RelativeDirectoryPath deep = RelativeDirectoryPath.Create(TestPaths.Relative("a", "b", "c", "d")); RelativeDirectoryPath parent1 = deep.Parent; RelativeDirectoryPath parent2 = parent1.Parent; @@ -133,7 +133,7 @@ public void PathHierarchy_ParentTraversal_WorksCorrectly() public void PathNormalization_WithDotComponents_ResolvesCorrectly() { // Test path normalization with . and .. components - RelativeDirectoryPath pathWithDots = RelativeDirectoryPath.Create(@"a\.\b\..\c"); + RelativeDirectoryPath pathWithDots = RelativeDirectoryPath.Create(TestPaths.Relative("a", ".", "b", "..", "c")); RelativeDirectoryPath normalized = pathWithDots.Normalize(); @@ -148,7 +148,7 @@ public void PathNormalization_WithDotComponents_ResolvesCorrectly() public void PathNormalization_WithParentTraversal_ResolvesCorrectly() { // Test path normalization with parent directory traversal - RelativeDirectoryPath path = RelativeDirectoryPath.Create(@"a\b\..\..\c"); + RelativeDirectoryPath path = RelativeDirectoryPath.Create(TestPaths.Relative("a", "b", "..", "..", "c")); RelativeDirectoryPath normalized = path.Normalize(); @@ -180,8 +180,8 @@ public void DirectoryDepth_Comparison_WorksCorrectly() { // Test comparing depths of different paths RelativeDirectoryPath shallow = RelativeDirectoryPath.Create("a"); - RelativeDirectoryPath medium = RelativeDirectoryPath.Create(@"a\b"); - RelativeDirectoryPath deep = RelativeDirectoryPath.Create(@"a\b\c"); + RelativeDirectoryPath medium = RelativeDirectoryPath.Create(TestPaths.Relative("a", "b")); + RelativeDirectoryPath deep = RelativeDirectoryPath.Create(TestPaths.Relative("a", "b", "c")); Assert.IsLessThan(medium.Depth, shallow.Depth); Assert.IsLessThan(deep.Depth, medium.Depth); @@ -194,7 +194,7 @@ public void DirectoryDepth_Comparison_WorksCorrectly() public void InterfaceBasedPathOperations_WorkPolymorphically() { // Test that interface-based operations work polymorphically - IDirectoryPath dir1 = AbsoluteDirectoryPath.Create(@"C:\test"); + IDirectoryPath dir1 = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); IDirectoryPath dir2 = RelativeDirectoryPath.Create("test"); IDirectoryPath dir3 = DirectoryPath.Create("test"); @@ -258,8 +258,8 @@ public void RelativePaths_WithDotDot_AreValid() { // Test that relative paths with .. components are valid RelativeDirectoryPath parentRef = RelativeDirectoryPath.Create(".."); - RelativeDirectoryPath multiParent = RelativeDirectoryPath.Create(@"..\..\.."); - RelativeFilePath fileInParent = RelativeFilePath.Create(@"..\file.txt"); + RelativeDirectoryPath multiParent = RelativeDirectoryPath.Create(TestPaths.Relative("..", "..", "..")); + RelativeFilePath fileInParent = RelativeFilePath.Create(TestPaths.Relative("..", "file.txt")); Assert.IsNotNull(parentRef); Assert.IsNotNull(multiParent); @@ -274,8 +274,8 @@ public void RelativePaths_WithDot_AreValid() { // Test that relative paths with . components are valid RelativeDirectoryPath currentRef = RelativeDirectoryPath.Create("."); - RelativeDirectoryPath withCurrent = RelativeDirectoryPath.Create(@".\subfolder"); - RelativeFilePath fileInCurrent = RelativeFilePath.Create(@".\file.txt"); + RelativeDirectoryPath withCurrent = RelativeDirectoryPath.Create(TestPaths.Relative(".", "subfolder")); + RelativeFilePath fileInCurrent = RelativeFilePath.Create(TestPaths.Relative(".", "file.txt")); Assert.IsNotNull(currentRef); Assert.IsNotNull(withCurrent); diff --git a/Semantics.Test/Paths/PathOperatorTests.cs b/Semantics.Test/Paths/PathOperatorTests.cs index b61188ae..4ca5068b 100644 --- a/Semantics.Test/Paths/PathOperatorTests.cs +++ b/Semantics.Test/Paths/PathOperatorTests.cs @@ -13,7 +13,7 @@ public class PathOperatorTests public void PathOperators_NullArguments_ThrowArgumentNullException() { // Test all path combination operators with null arguments - AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); DirectoryPath genericDir = DirectoryPath.Create(@"test"); FileName nullFileName = null!; @@ -32,8 +32,8 @@ public void PathOperators_NullArguments_ThrowArgumentNullException() public void PathOperators_ComplexCombinations_WorkCorrectly() { // Test complex path combinations - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\projects"); - DirectoryPath subDir1 = DirectoryPath.Create(@"app\src"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); + DirectoryPath subDir1 = DirectoryPath.Create(TestPaths.Relative("app", "src")); DirectoryPath subDir2 = DirectoryPath.Create(@"components"); FileName fileName = FileName.Create("Component.tsx"); @@ -44,7 +44,7 @@ public void PathOperators_ComplexCombinations_WorkCorrectly() Assert.IsNotNull(combinedDir); Assert.IsNotNull(finalFile); - Assert.Contains(@"C:\projects", finalFile.WeakString); + Assert.Contains(TestPaths.Absolute("projects"), finalFile.WeakString); Assert.Contains(@"app", finalFile.WeakString); Assert.Contains(@"src", finalFile.WeakString); Assert.Contains(@"components", finalFile.WeakString); @@ -55,7 +55,7 @@ public void PathOperators_ComplexCombinations_WorkCorrectly() public void PathOperators_EmptyPaths_HandleCorrectly() { // Test operators with empty paths - AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); FileName emptyFileName = FileName.Create(""); // These should work without throwing @@ -68,7 +68,7 @@ public void PathOperators_EmptyPaths_HandleCorrectly() public void PathOperators_SpecialCharacters_HandleCorrectly() { // Test with paths containing special characters - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\test folder"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test folder")); DirectoryPath specialDir = DirectoryPath.Create(@"sub folder (1)"); FileName specialFile = FileName.Create("file name with spaces.txt"); @@ -87,7 +87,7 @@ public void PathOperators_SpecialCharacters_HandleCorrectly() public void PathOperators_ReturnTypes_AreCorrect() { // Verify that operators return the correct types - AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); DirectoryPath genericDir = DirectoryPath.Create(@"test"); FileName fileName = FileName.Create("file.txt"); @@ -106,10 +106,10 @@ public void PathOperators_ReturnTypes_AreCorrect() public void PathOperators_WithDotPaths_HandleCorrectly() { // Test with relative paths containing . and .. - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\projects\app"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects", "app")); RelativeDirectoryPath currentDir = RelativeDirectoryPath.Create("."); RelativeDirectoryPath parentDir = RelativeDirectoryPath.Create(".."); - RelativeDirectoryPath complexPath = RelativeDirectoryPath.Create(@"..\other\folder"); + RelativeDirectoryPath complexPath = RelativeDirectoryPath.Create(TestPaths.Relative("..", "other", "folder")); AbsoluteDirectoryPath result1 = baseDir / currentDir; AbsoluteDirectoryPath result2 = baseDir / parentDir; @@ -129,7 +129,7 @@ public void PathOperators_WithDotPaths_HandleCorrectly() public void PathOperators_CrossPlatformSeparators_HandleCorrectly() { // Test with mixed path separators - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); RelativeDirectoryPath unixStyleDir = RelativeDirectoryPath.Create("sub/folder"); RelativeFilePath unixStyleFile = RelativeFilePath.Create("sub/file.txt"); @@ -146,14 +146,14 @@ public void PathOperators_CrossPlatformSeparators_HandleCorrectly() public void DirectoryNameOperator_WithAbsoluteDirectoryPath_CreatesCorrectPath() { // Test combining AbsoluteDirectoryPath with DirectoryName - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\projects"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); DirectoryName subDir = DirectoryName.Create("myapp"); AbsoluteDirectoryPath result = baseDir / subDir; Assert.IsNotNull(result); Assert.IsTrue(result.IsValid()); - Assert.Contains(@"C:\projects", result.WeakString); + Assert.Contains(TestPaths.Absolute("projects"), result.WeakString); Assert.Contains("myapp", result.WeakString); } @@ -195,7 +195,7 @@ public void DirectoryNameOperator_ChainedCombinations_WorkCorrectly() public void DirectoryNameOperator_WithNullDirectoryName_ThrowsArgumentNullException() { // Test null safety for DirectoryName operators - AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); DirectoryPath genericDir = DirectoryPath.Create(@"test"); DirectoryName nullDirName = null!; diff --git a/Semantics.Test/Paths/PathUtilityTests.cs b/Semantics.Test/Paths/PathUtilityTests.cs index 188ecf16..6829637b 100644 --- a/Semantics.Test/Paths/PathUtilityTests.cs +++ b/Semantics.Test/Paths/PathUtilityTests.cs @@ -13,8 +13,8 @@ public class PathUtilityTests public void IsChildOf_WithValidChildPath_ReturnsTrue() { // Test IsChildOf with valid parent-child relationship - AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(@"C:\projects"); - AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create(@"C:\projects\app\src"); + AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); + AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects", "app", "src")); bool result = child.IsChildOf(parent); @@ -25,8 +25,8 @@ public void IsChildOf_WithValidChildPath_ReturnsTrue() public void IsChildOf_WithSamePath_ReturnsFalse() { // Test IsChildOf with identical paths (should return false) - AbsoluteDirectoryPath path1 = AbsoluteDirectoryPath.Create(@"C:\projects"); - AbsoluteDirectoryPath path2 = AbsoluteDirectoryPath.Create(@"C:\projects"); + AbsoluteDirectoryPath path1 = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); + AbsoluteDirectoryPath path2 = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); bool result = path1.IsChildOf(path2); @@ -37,8 +37,8 @@ public void IsChildOf_WithSamePath_ReturnsFalse() public void IsChildOf_WithNonChildPath_ReturnsFalse() { // Test IsChildOf with unrelated paths - AbsoluteDirectoryPath path1 = AbsoluteDirectoryPath.Create(@"C:\projects\app"); - AbsoluteDirectoryPath path2 = AbsoluteDirectoryPath.Create(@"C:\other\folder"); + AbsoluteDirectoryPath path1 = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects", "app")); + AbsoluteDirectoryPath path2 = AbsoluteDirectoryPath.Create(TestPaths.Absolute("other", "folder")); bool result = path1.IsChildOf(path2); @@ -49,8 +49,8 @@ public void IsChildOf_WithNonChildPath_ReturnsFalse() public void IsChildOf_WithParentAsChild_ReturnsFalse() { // Test IsChildOf with parent-child relationship reversed - AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(@"C:\projects"); - AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create(@"C:\projects\app"); + AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); + AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects", "app")); bool result = parent.IsChildOf(child); @@ -61,7 +61,7 @@ public void IsChildOf_WithParentAsChild_ReturnsFalse() public void IsChildOf_WithNullArgument_ThrowsArgumentNullException() { // Test IsChildOf with null argument - AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); Assert.ThrowsExactly(() => path.IsChildOf(null!)); } @@ -70,8 +70,9 @@ public void IsChildOf_WithNullArgument_ThrowsArgumentNullException() public void IsChildOf_WithMixedSeparators_WorksCorrectly() { // Test IsChildOf with mixed path separators - AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(@"C:\projects"); - AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create(@"C:/projects/app/src"); + AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); + AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create( + TestPaths.AltAbsolute("projects", "app", "src")); bool result = child.IsChildOf(parent); @@ -82,8 +83,8 @@ public void IsChildOf_WithMixedSeparators_WorksCorrectly() public void GetRelativePathTo_WithValidPaths_ReturnsCorrectRelativePath() { // Test GetRelativePathTo with valid directory paths - AbsoluteDirectoryPath from = AbsoluteDirectoryPath.Create(@"C:\projects\app"); - AbsoluteDirectoryPath to = AbsoluteDirectoryPath.Create(@"C:\projects\lib\utils"); + AbsoluteDirectoryPath from = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects", "app")); + AbsoluteDirectoryPath to = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects", "lib", "utils")); RelativeDirectoryPath result = from.GetRelativePathTo(to); @@ -97,8 +98,8 @@ public void GetRelativePathTo_WithValidPaths_ReturnsCorrectRelativePath() public void GetRelativePathTo_WithSamePath_ReturnsCurrentDirectory() { // Test GetRelativePathTo with identical paths - AbsoluteDirectoryPath path1 = AbsoluteDirectoryPath.Create(@"C:\projects"); - AbsoluteDirectoryPath path2 = AbsoluteDirectoryPath.Create(@"C:\projects"); + AbsoluteDirectoryPath path1 = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); + AbsoluteDirectoryPath path2 = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); RelativeDirectoryPath result = path1.GetRelativePathTo(path2); @@ -111,7 +112,7 @@ public void GetRelativePathTo_WithSamePath_ReturnsCurrentDirectory() public void GetRelativePathTo_WithNullArgument_ThrowsArgumentNullException() { // Test GetRelativePathTo with null argument - AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(@"C:\test"); + AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test")); Assert.ThrowsExactly(() => path.GetRelativePathTo(null!)); } @@ -120,8 +121,8 @@ public void GetRelativePathTo_WithNullArgument_ThrowsArgumentNullException() public void GetRelativePathTo_WithChildPath_ReturnsSimpleRelativePath() { // Test GetRelativePathTo from parent to child - AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(@"C:\projects"); - AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create(@"C:\projects\app\src"); + AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects")); + AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create(TestPaths.Absolute("projects", "app", "src")); RelativeDirectoryPath result = parent.GetRelativePathTo(child); @@ -135,7 +136,7 @@ public void GetRelativePathTo_WithChildPath_ReturnsSimpleRelativePath() public void Normalize_WithDotPaths_ResolvesCorrectly() { // Test Normalize with . and .. components - RelativeDirectoryPath complexPath = RelativeDirectoryPath.Create(@"app\.\src\..\lib\utils"); + RelativeDirectoryPath complexPath = RelativeDirectoryPath.Create(TestPaths.Relative("app", ".", "src", "..", "lib", "utils")); RelativeDirectoryPath normalized = complexPath.Normalize(); @@ -161,19 +162,19 @@ public void Normalize_WithEmptyPath_ReturnsEmpty() public void Normalize_WithSimplePath_ReturnsSame() { // Test Normalize with already normalized path - RelativeDirectoryPath simplePath = RelativeDirectoryPath.Create(@"app\src"); + RelativeDirectoryPath simplePath = RelativeDirectoryPath.Create(TestPaths.Relative("app", "src")); RelativeDirectoryPath normalized = simplePath.Normalize(); Assert.IsNotNull(normalized); - Assert.AreEqual("app" + Path.DirectorySeparatorChar + "src", normalized.WeakString); + Assert.AreEqual(TestPaths.Relative("app", "src"), normalized.WeakString); } [TestMethod] public void Normalize_WithOnlyDots_ResolvesCorrectly() { // Test Normalize with only . and .. components - RelativeDirectoryPath dotPath = RelativeDirectoryPath.Create(@".\..\.\folder"); + RelativeDirectoryPath dotPath = RelativeDirectoryPath.Create(TestPaths.Relative(".", "..", ".", "folder")); RelativeDirectoryPath normalized = dotPath.Normalize(); @@ -185,7 +186,7 @@ public void Normalize_WithOnlyDots_ResolvesCorrectly() public void RemoveExtension_WithValidExtension_RemovesCorrectly() { // Test RemoveExtension on RelativeFilePath - RelativeFilePath filePath = RelativeFilePath.Create(@"app\src\component.tsx"); + RelativeFilePath filePath = RelativeFilePath.Create(TestPaths.Relative("app", "src", "component.tsx")); RelativeFilePath result = filePath.RemoveExtension(); @@ -211,7 +212,7 @@ public void RemoveExtension_WithMultipleExtensions_RemovesLastOnly() public void RemoveExtension_WithNoExtension_ReturnsUnchanged() { // Test RemoveExtension with file that has no extension - RelativeFilePath filePath = RelativeFilePath.Create(@"app\src\README"); + RelativeFilePath filePath = RelativeFilePath.Create(TestPaths.Relative("app", "src", "README")); RelativeFilePath result = filePath.RemoveExtension(); @@ -224,7 +225,7 @@ public void PathDepth_CalculatesCorrectly() { // Test depth calculation for relative directory paths RelativeDirectoryPath shallowPath = RelativeDirectoryPath.Create("folder"); - RelativeDirectoryPath deepPath = RelativeDirectoryPath.Create(@"app\src\components\ui"); + RelativeDirectoryPath deepPath = RelativeDirectoryPath.Create(TestPaths.Relative("app", "src", "components", "ui")); int shallowDepth = shallowPath.Depth; int deepDepth = deepPath.Depth; @@ -248,7 +249,7 @@ public void PathDepth_WithEmptyPath_ReturnsZero() public void PathDepth_WithMixedSeparators_CountsCorrectly() { // Test depth calculation with mixed separators - RelativeDirectoryPath mixedPath = RelativeDirectoryPath.Create(@"app/src\components"); + RelativeDirectoryPath mixedPath = RelativeDirectoryPath.Create(TestPaths.AltRelative("app", "src") + TestPaths.Separator + "components"); int depth = mixedPath.Depth; diff --git a/Semantics.Test/Paths/RelativePathPropertyTests.cs b/Semantics.Test/Paths/RelativePathPropertyTests.cs index 5c78d904..30c50f27 100644 --- a/Semantics.Test/Paths/RelativePathPropertyTests.cs +++ b/Semantics.Test/Paths/RelativePathPropertyTests.cs @@ -13,7 +13,7 @@ public class RelativePathPropertyTests public void RelativeDirectoryPath_Name_ReturnsCorrectDirectoryName() { // Test that Name property returns the last component as DirectoryName - RelativeDirectoryPath path = RelativeDirectoryPath.Create(@"projects\app\src"); + RelativeDirectoryPath path = RelativeDirectoryPath.Create(TestPaths.Relative("projects", "app", "src")); DirectoryName name = path.Name; @@ -49,7 +49,7 @@ public void RelativeDirectoryPath_Name_WithEmptyPath_ReturnsEmpty() public void RelativeDirectoryPath_Parent_ReturnsCorrectParent() { // Test that Parent property returns parent directory - RelativeDirectoryPath path = RelativeDirectoryPath.Create(@"projects\app\src"); + RelativeDirectoryPath path = RelativeDirectoryPath.Create(TestPaths.Relative("projects", "app", "src")); RelativeDirectoryPath parent = path.Parent; @@ -76,8 +76,8 @@ public void RelativeDirectoryPath_Depth_CalculatesCorrectly() { // Test Depth property calculation RelativeDirectoryPath shallow = RelativeDirectoryPath.Create("myapp"); - RelativeDirectoryPath medium = RelativeDirectoryPath.Create(@"projects\myapp"); - RelativeDirectoryPath deep = RelativeDirectoryPath.Create(@"projects\myapp\src\components"); + RelativeDirectoryPath medium = RelativeDirectoryPath.Create(TestPaths.Relative("projects", "myapp")); + RelativeDirectoryPath deep = RelativeDirectoryPath.Create(TestPaths.Relative("projects", "myapp", "src", "components")); Assert.AreEqual(0, shallow.Depth); Assert.AreEqual(1, medium.Depth); @@ -97,7 +97,7 @@ public void RelativeDirectoryPath_Depth_WithEmptyPath_ReturnsZero() public void RelativeFilePath_RelativeDirectoryPath_ReturnsCorrectDirectory() { // Test RelativeDirectoryPath property - RelativeFilePath file = RelativeFilePath.Create(@"projects\app\Component.tsx"); + RelativeFilePath file = RelativeFilePath.Create(TestPaths.Relative("projects", "app", "Component.tsx")); RelativeDirectoryPath dir = file.RelativeDirectoryPath; @@ -123,7 +123,7 @@ public void RelativeFilePath_RelativeDirectoryPath_WithFileInRoot_ReturnsEmpty() public void RelativeFilePath_FileNameWithoutExtension_ReturnsCorrectName() { // Test FileNameWithoutExtension property - RelativeFilePath file = RelativeFilePath.Create(@"projects\Component.tsx"); + RelativeFilePath file = RelativeFilePath.Create(TestPaths.Relative("projects", "Component.tsx")); FileName name = file.FileNameWithoutExtension; @@ -147,7 +147,7 @@ public void RelativeFilePath_FileNameWithoutExtension_WithMultipleExtensions_Rem public void RelativeFilePath_ChangeExtension_ChangesCorrectly() { // Test ChangeExtension method - RelativeFilePath file = RelativeFilePath.Create(@"projects\file.txt"); + RelativeFilePath file = RelativeFilePath.Create(TestPaths.Relative("projects", "file.txt")); FileExtension newExt = FileExtension.Create(".md"); RelativeFilePath result = file.ChangeExtension(newExt); @@ -170,7 +170,7 @@ public void RelativeFilePath_ChangeExtension_WithNullExtension_ThrowsException() public void RelativeFilePath_RemoveExtension_RemovesCorrectly() { // Test RemoveExtension method - RelativeFilePath file = RelativeFilePath.Create(@"projects\file.txt"); + RelativeFilePath file = RelativeFilePath.Create(TestPaths.Relative("projects", "file.txt")); RelativeFilePath result = file.RemoveExtension(); @@ -183,7 +183,7 @@ public void RelativeFilePath_RemoveExtension_RemovesCorrectly() public void RelativeFilePath_RemoveExtension_WithNoExtension_ReturnsUnchanged() { // Test RemoveExtension with file without extension - RelativeFilePath file = RelativeFilePath.Create(@"projects\README"); + RelativeFilePath file = RelativeFilePath.Create(TestPaths.Relative("projects", "README")); RelativeFilePath result = file.RemoveExtension(); @@ -238,7 +238,7 @@ public void RelativeDirectoryPath_AsRelative_ReturnsSelf() { // Test that AsRelative returns self for already relative paths RelativeDirectoryPath path = RelativeDirectoryPath.Create("projects"); - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\temp"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("temp")); RelativeDirectoryPath result = path.AsRelative(baseDir); @@ -250,7 +250,7 @@ public void RelativeFilePath_AsRelative_ReturnsSelf() { // Test that AsRelative returns self for already relative file paths RelativeFilePath path = RelativeFilePath.Create("file.txt"); - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\temp"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("temp")); RelativeFilePath result = path.AsRelative(baseDir); @@ -262,12 +262,12 @@ public void RelativeDirectoryPath_AsAbsoluteWithBase_ResolvesCorrectly() { // Test AsAbsolute with explicit base directory RelativeDirectoryPath relative = RelativeDirectoryPath.Create("projects"); - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\work"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("work")); AbsoluteDirectoryPath result = relative.AsAbsolute(baseDir); Assert.IsNotNull(result); - Assert.Contains(@"C:\work", result.WeakString); + Assert.Contains(TestPaths.Absolute("work"), result.WeakString); Assert.Contains("projects", result.WeakString); } @@ -276,12 +276,12 @@ public void RelativeFilePath_AsAbsoluteWithBase_ResolvesCorrectly() { // Test AsAbsolute with explicit base directory for files RelativeFilePath relative = RelativeFilePath.Create("file.txt"); - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(@"C:\work"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("work")); AbsoluteFilePath result = relative.AsAbsolute(baseDir); Assert.IsNotNull(result); - Assert.Contains(@"C:\work", result.WeakString); + Assert.Contains(TestPaths.Absolute("work"), result.WeakString); Assert.Contains("file.txt", result.WeakString); } diff --git a/Semantics.Test/Paths/SemanticPathInterfaceTests.cs b/Semantics.Test/Paths/SemanticPathInterfaceTests.cs index bb985540..292f0a1f 100644 --- a/Semantics.Test/Paths/SemanticPathInterfaceTests.cs +++ b/Semantics.Test/Paths/SemanticPathInterfaceTests.cs @@ -14,7 +14,7 @@ public void DirectoryPath_CombineWithFileName_WorksCorrectly() // Arrange FileName fileName = FileName.Create("test.txt"); - AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(@"C:\temp"); + AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("temp")); DirectoryPath genericDir = DirectoryPath.Create(@"temp"); // Act @@ -25,7 +25,7 @@ public void DirectoryPath_CombineWithFileName_WorksCorrectly() Assert.IsNotNull(absoluteResult); Assert.IsNotNull(genericResult); - Assert.AreEqual(@"C:\temp\test.txt", absoluteResult.WeakString); - Assert.AreEqual(@"temp\test.txt", genericResult.WeakString); + Assert.AreEqual(TestPaths.Absolute("temp", "test.txt"), absoluteResult.WeakString); + Assert.AreEqual(TestPaths.Relative("temp", "test.txt"), genericResult.WeakString); } } diff --git a/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs b/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs index 8dccde9d..50a20a0f 100644 --- a/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs +++ b/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs @@ -5,6 +5,7 @@ namespace ktsu.Semantics.Test.Quantities; using System.Collections.Generic; using System.Linq; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.TestTools.UnitTesting; using global::Semantics.SourceGenerators; @@ -45,6 +46,39 @@ private static string DimensionsDocument(string relationships = "", string avail } """; + /// + /// Two dimensions, so a relationship's other and result can name different things + /// and a diagnostic about one can be told from a diagnostic about the other. + /// + /// Relationship JSON to splice into the Length dimension. + /// The document. + /// + /// Time deliberately declares only vector0. Length declares vector0 and + /// vector3, so a relationship between them at form 3 is honourable for Length and not for + /// Time — which is what SEM003 is for. + /// + private static string TwoDimensionsDocument(string relationships) => + $$""" + { + "physicalDimensions": [ + { + "name": "Length", + "symbol": "L", + "dimensionalFormula": { "length": 1 }, + "availableUnits": [ "Meter" ], + "quantities": { "vector0": { "base": "Length" }, "vector3": { "base": "Displacement3D" } }{{relationships}} + }, + { + "name": "Time", + "symbol": "T", + "dimensionalFormula": { "time": 1 }, + "availableUnits": [ "Second" ], + "quantities": { "vector0": { "base": "Duration" } } + } + ] + } + """; + private static IReadOnlyList Run(string generatorMetadata, IIncrementalGenerator generator, string fileName) => [.. Harness.Run(generator, new Dictionary { [fileName] = generatorMetadata }).Diagnostics]; @@ -55,6 +89,39 @@ private static void AssertReports(IReadOnlyList diagnostics, string $"Expected {id}. Got: {(diagnostics.Count == 0 ? "no diagnostics" : string.Join("; ", diagnostics.Select(d => $"{d.Id}: {d.GetMessage()}")))}"); } + /// + /// Asserts that a diagnostic points at a specific piece of text in the metadata it was reported + /// against. + /// + /// The metadata document the generator was run over. + /// Everything the generator reported. + /// The diagnostic to look for. + /// The text the location should cover. + /// + /// Asserting only that the location is not would pass for a location + /// pointing at the wrong entry, which is the failure mode that actually matters: every name in a + /// relationship is spelled correctly somewhere else in the file, so an unscoped search lands + /// plausibly and uselessly far from the mistake. Reading the covered text back proves it landed + /// on the right one. + /// + private static void AssertPointsAt(string metadata, IReadOnlyList diagnostics, string id, string expected) + { + AssertReports(diagnostics, id); + Diagnostic diagnostic = diagnostics.First(candidate => candidate.Id == id); + + Assert.AreNotEqual( + Location.None, + diagnostic.Location, + $"{id} is only actionable if it says where in the metadata the problem is."); + Assert.EndsWith("dimensions.json", diagnostic.Location.GetLineSpan().Path); + + TextSpan span = diagnostic.Location.SourceSpan; + Assert.AreEqual( + expected, + metadata.Substring(span.Start, span.Length), + $"{id} pointed at the wrong place in the metadata."); + } + [TestMethod] public void Sem001_IsReportedForARelationshipNamingAnUnknownDimension() { @@ -64,6 +131,50 @@ public void Sem001_IsReportedForARelationshipNamingAnUnknownDimension() AssertReports(Run(metadata, new QuantitiesGenerator(), "dimensions.json"), "SEM001"); } + [TestMethod] + public void Sem001_PointsAtTheMisspelledNameRatherThanAtNothing() + { + string metadata = DimensionsDocument( + relationships: ",\n \"integrals\": [ { \"other\": \"Tiem\", \"result\": \"Length\" } ]"); + + AssertPointsAt(metadata, Run(metadata, new QuantitiesGenerator(), "dimensions.json"), "SEM001", "Tiem"); + } + + /// + /// SEM001 fires for a bad name in any relationship kind, in either field. + /// + /// The relationship array to put the bad name in. + /// Which of other/result is wrong. + /// + /// Each of these is a separate report site with its own field-path message, and only + /// integrals.other had a test. A rename that dropped one of the other seven would not + /// have failed anything. + /// + [TestMethod] + [DataRow("integrals", "other")] + [DataRow("integrals", "result")] + [DataRow("derivatives", "other")] + [DataRow("derivatives", "result")] + [DataRow("dotProducts", "other")] + [DataRow("dotProducts", "result")] + [DataRow("crossProducts", "other")] + [DataRow("crossProducts", "result")] + public void Sem001_IsReportedForAnUnknownNameInAnyRelationshipField(string kind, string field) + { + string other = field == "other" ? "Nonexistent" : "Time"; + string result = field == "result" ? "Nonexistent" : "Time"; + string metadata = TwoDimensionsDocument( + $",\n \"{kind}\": [ {{ \"other\": \"{other}\", \"result\": \"{result}\" }} ]"); + + IReadOnlyList diagnostics = Run(metadata, new QuantitiesGenerator(), "dimensions.json"); + + AssertPointsAt(metadata, diagnostics, "SEM001", "Nonexistent"); + Assert.Contains( + $"{kind}[{other} -> {result}].{field}", + diagnostics.First(candidate => candidate.Id == "SEM001").GetMessage(), + "The message should name the field path the bad name is written at."); + } + [TestMethod] public void Sem002_IsReportedForADimensionMissingItsSymbol() { @@ -93,6 +204,55 @@ public void Sem003_IsReportedWhenARelationshipRequestsAnUndeclaredForm() AssertReports(Run(metadata, new QuantitiesGenerator(), "dimensions.json"), "SEM003"); } + [TestMethod] + public void Sem003_PointsAtTheRelationshipRatherThanAtNothing() + { + string metadata = DimensionsDocument( + relationships: ",\n \"crossProducts\": [ { \"other\": \"Length\", \"result\": \"Length\", \"forms\": [ 2 ] } ]"); + + // Not the bare name: "Length" is spelled correctly and appears several times before the + // relationship that is wrong. The location has to be the relationship's own "other". + AssertPointsAt( + metadata, + Run(metadata, new QuantitiesGenerator(), "dimensions.json"), + "SEM003", + "\"other\": \"Length\""); + } + + /// + /// The self branch is covered above. This is the second participant: Length has a vector3 and + /// Time does not, so the cross product cannot be honoured at form 3 — and the diagnostic has to + /// name Time rather than Length. + /// + [TestMethod] + public void Sem003_NamesTheOtherParticipantWhenItIsTheOneMissingTheForm() + { + string metadata = TwoDimensionsDocument( + ",\n \"crossProducts\": [ { \"other\": \"Time\", \"result\": \"Length\", \"forms\": [ 3 ] } ]"); + + IReadOnlyList diagnostics = Run(metadata, new QuantitiesGenerator(), "dimensions.json"); + + AssertReports(diagnostics, "SEM003"); + Assert.Contains("Time", diagnostics.First(candidate => candidate.Id == "SEM003").GetMessage()); + } + + /// + /// The third participant. A cross product also needs its result to have the form — + /// Force x Length -> Torque at V2 fails because Torque has no V2, not because either operand + /// is missing one. + /// + [TestMethod] + public void Sem003_NamesTheResultWhenItIsTheOneMissingTheForm() + { + string metadata = TwoDimensionsDocument( + ",\n \"crossProducts\": [ { \"other\": \"Length\", \"result\": \"Time\", \"forms\": [ 3 ] } ]"); + + IReadOnlyList diagnostics = Run(metadata, new QuantitiesGenerator(), "dimensions.json"); + + AssertReports(diagnostics, "SEM003"); + Assert.Contains("Time", diagnostics.First(candidate => candidate.Id == "SEM003").GetMessage()); + } + [TestMethod] public void Sem004_IsReportedForAUnitThatUnitsJsonDoesNotDeclare() { @@ -106,14 +266,7 @@ public void Sem004_PointsAtWhereTheUnitIsWrittenRatherThanAtNothing() { string metadata = DimensionsDocument(availableUnits: "\"Meter\", \"Kilometres\""); - Diagnostic diagnostic = Run(metadata, new QuantitiesGenerator(), "dimensions.json") - .First(candidate => candidate.Id == "SEM004"); - - Assert.AreNotEqual( - Location.None, - diagnostic.Location, - "A warning about a name in a large JSON file is only actionable if it says where the name is."); - Assert.EndsWith("dimensions.json", diagnostic.Location.GetLineSpan().Path); + AssertPointsAt(metadata, Run(metadata, new QuantitiesGenerator(), "dimensions.json"), "SEM004", "Kilometres"); } [TestMethod] diff --git a/Semantics.Test/Quantities/MetadataFileTests.cs b/Semantics.Test/Quantities/MetadataFileTests.cs new file mode 100644 index 00000000..89d4e35a --- /dev/null +++ b/Semantics.Test/Quantities/MetadataFileTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test.Quantities; + +using System; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using global::Semantics.SourceGenerators.CodeGen; + +/// +/// Covers directly. +/// +/// +/// The generator tests reach the happy path of both overloads, but not what happens when a search +/// comes up empty — and the fallbacks are the whole reason the scoped overload is safe to use. A +/// metadata layout it does not recognise has to degrade to the unscoped search and then to +/// , never throw and never point somewhere arbitrary. +/// +[TestClass] +public class MetadataFileTests +{ + private const string Document = + """ + { + "physicalDimensions": [ + { "name": "Length", "symbol": "L", "integrals": [ { "other": "Time" } ] }, + { "name": "Time", "symbol": "T", "integrals": [ { "other": "Length" } ] } + ] + } + """; + + private static MetadataFile File(string text = Document) => + new("dimensions.json", text, SourceText.From(text), "/metadata/dimensions.json"); + + private static string Covered(Location location) => + Document.Substring(location.SourceSpan.Start, location.SourceSpan.Length); + + [TestMethod] + public void TheScopedSearchStartsAtItsAnchorRatherThanAtTheTopOfTheFile() + { + // "Length" appears first as this dimension's own name, and again as Time's integral. The + // second one is the one an SEM003 about Time is talking about. + Location location = File().FindLocation("\"name\": \"Time\"", "\"other\": \"Length\""); + + Assert.AreEqual("\"other\": \"Length\"", Covered(location)); + Assert.IsTrue( + location.SourceSpan.Start > Document.IndexOf("\"name\": \"Time\"", StringComparison.Ordinal), + "The scoped search must land after its anchor, not before it."); + } + + [TestMethod] + public void AMissingAnchorFallsBackToAnUnscopedSearch() + { + Location location = File().FindLocation("\"name\": \"Nonexistent\"", "symbol"); + + Assert.AreEqual( + Document.IndexOf("symbol", StringComparison.Ordinal), + location.SourceSpan.Start, + "An unrecognised anchor should degrade to the unscoped search, not to nothing."); + } + + [TestMethod] + public void ANeedleThatOnlyAppearsBeforeTheAnchorFallsBackToAnUnscopedSearch() + { + // "symbol": "L" is in the first entry only, before the Time anchor. + Location location = File().FindLocation("\"name\": \"Time\"", "\"symbol\": \"L\""); + + Assert.AreEqual("\"symbol\": \"L\"", Covered(location)); + } + + [TestMethod] + public void AnEmptyAnchorIsAnUnscopedSearch() + { + Location location = File().FindLocation("", "symbol"); + + Assert.AreEqual(Document.IndexOf("symbol", StringComparison.Ordinal), location.SourceSpan.Start); + } + + [TestMethod] + public void AnEmptyNeedleHasNoLocation() + { + Assert.AreEqual(Location.None, File().FindLocation("\"name\": \"Time\"", "")); + } + + [TestMethod] + public void ANeedleThatIsNowhereInTheFileHasNoLocation() + { + Assert.AreEqual(Location.None, File().FindLocation("\"name\": \"Time\"", "nowhere")); + Assert.AreEqual(Location.None, File().FindLocation("nowhere")); + } + + /// + /// An AdditionalText whose GetText returned null. There is nothing to build a + /// location against, so both overloads have to say so rather than throw. + /// + [TestMethod] + public void AFileWithNoSourceTextHasNoLocations() + { + MetadataFile file = new("dimensions.json", Document, null, "/metadata/dimensions.json"); + + Assert.AreEqual(Location.None, file.FindLocation("symbol")); + Assert.AreEqual(Location.None, file.FindLocation("\"name\": \"Time\"", "symbol")); + } +} diff --git a/Semantics.Test/SemanticPathInterfaceTests.cs b/Semantics.Test/SemanticPathInterfaceTests.cs index 6a05c0b3..800ba705 100644 --- a/Semantics.Test/SemanticPathInterfaceTests.cs +++ b/Semantics.Test/SemanticPathInterfaceTests.cs @@ -12,7 +12,7 @@ public class SemanticPathInterfaceTests public void AbsolutePath_ImplementsIAbsolutePathAndIPath() { // Arrange & Act - AbsolutePath absolutePath = AbsolutePath.Create("C:\\test\\path"); + AbsolutePath absolutePath = AbsolutePath.Create(TestPaths.Absolute("test", "path")); IAbsolutePath iAbsolutePath = absolutePath; IPath iPath = absolutePath; @@ -80,7 +80,7 @@ public void DirectoryPath_ImplementsIDirectoryPathAndIPath() public void AbsoluteFilePath_ImplementsAllApplicableInterfaces() { // Arrange & Act - AbsoluteFilePath absoluteFilePath = AbsoluteFilePath.Create("C:\\test\\file.txt"); + AbsoluteFilePath absoluteFilePath = AbsoluteFilePath.Create(TestPaths.Absolute("test", "file.txt")); IAbsoluteFilePath iAbsoluteFilePath = absoluteFilePath; IFilePath iFilePath = absoluteFilePath; IAbsolutePath iAbsolutePath = absoluteFilePath; @@ -130,7 +130,7 @@ public void RelativeFilePath_ImplementsAllApplicableInterfaces() public void AbsoluteDirectoryPath_ImplementsAllApplicableInterfaces() { // Arrange & Act - AbsoluteDirectoryPath absoluteDirectoryPath = AbsoluteDirectoryPath.Create("C:\\test\\directory"); + AbsoluteDirectoryPath absoluteDirectoryPath = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test", "directory")); IAbsoluteDirectoryPath iAbsoluteDirectoryPath = absoluteDirectoryPath; IDirectoryPath iDirectoryPath = absoluteDirectoryPath; IAbsolutePath iAbsolutePath = absoluteDirectoryPath; @@ -213,23 +213,23 @@ public void PolymorphicCollection_CanStoreAllPathTypes() List relativePaths = []; // Act - paths.Add(AbsolutePath.Create("C:\\test\\path")); - paths.Add(AbsolutePath.Create("C:\\absolute\\path")); + paths.Add(AbsolutePath.Create(TestPaths.Absolute("test", "path"))); + paths.Add(AbsolutePath.Create(TestPaths.Absolute("absolute", "path"))); paths.Add(RelativePath.Create("relative\\path")); paths.Add(FilePath.Create("file.txt")); paths.Add(DirectoryPath.Create("directory")); - paths.Add(AbsoluteFilePath.Create("C:\\file.txt")); - paths.Add(AbsoluteDirectoryPath.Create("C:\\directory")); + paths.Add(AbsoluteFilePath.Create(TestPaths.Absolute("file.txt"))); + paths.Add(AbsoluteDirectoryPath.Create(TestPaths.Absolute("directory"))); filePaths.Add(FilePath.Create("file.txt")); - filePaths.Add(AbsoluteFilePath.Create("C:\\file.txt")); + filePaths.Add(AbsoluteFilePath.Create(TestPaths.Absolute("file.txt"))); directoryPaths.Add(DirectoryPath.Create("directory")); - directoryPaths.Add(AbsoluteDirectoryPath.Create("C:\\directory")); + directoryPaths.Add(AbsoluteDirectoryPath.Create(TestPaths.Absolute("directory"))); - absolutePaths.Add(AbsolutePath.Create("C:\\absolute\\path")); - absolutePaths.Add(AbsoluteFilePath.Create("C:\\file.txt")); - absolutePaths.Add(AbsoluteDirectoryPath.Create("C:\\directory")); + absolutePaths.Add(AbsolutePath.Create(TestPaths.Absolute("absolute", "path"))); + absolutePaths.Add(AbsoluteFilePath.Create(TestPaths.Absolute("file.txt"))); + absolutePaths.Add(AbsoluteDirectoryPath.Create(TestPaths.Absolute("directory"))); relativePaths.Add(RelativePath.Create("relative\\path")); @@ -257,23 +257,25 @@ public void PolymorphicMethods_CanAcceptInterfaceParameters() static string ProcessDirectoryPath(IDirectoryPath directoryPath) => $"Processing directory: {directoryPath}"; static string ProcessAbsolutePath(IAbsolutePath absolutePath) => $"Processing absolute: {absolutePath}"; - AbsoluteFilePath absoluteFilePath = AbsoluteFilePath.Create("C:\\test\\file.txt"); - DirectoryPath directoryPath = DirectoryPath.Create("test\\directory"); + string absoluteFile = TestPaths.Absolute("test", "file.txt"); + string relativeDirectory = TestPaths.Relative("test", "directory"); + AbsoluteFilePath absoluteFilePath = AbsoluteFilePath.Create(absoluteFile); + DirectoryPath directoryPath = DirectoryPath.Create(relativeDirectory); // Act & Assert - Test that polymorphic methods work - Assert.AreEqual("Processing path: C:\\test\\file.txt", ProcessPath(absoluteFilePath)); - Assert.AreEqual("Processing file: C:\\test\\file.txt", ProcessFilePath(absoluteFilePath)); - Assert.AreEqual("Processing absolute: C:\\test\\file.txt", ProcessAbsolutePath(absoluteFilePath)); + Assert.AreEqual($"Processing path: {absoluteFile}", ProcessPath(absoluteFilePath)); + Assert.AreEqual($"Processing file: {absoluteFile}", ProcessFilePath(absoluteFilePath)); + Assert.AreEqual($"Processing absolute: {absoluteFile}", ProcessAbsolutePath(absoluteFilePath)); - Assert.AreEqual("Processing path: test\\directory", ProcessPath(directoryPath)); - Assert.AreEqual("Processing directory: test\\directory", ProcessDirectoryPath(directoryPath)); + Assert.AreEqual($"Processing path: {relativeDirectory}", ProcessPath(directoryPath)); + Assert.AreEqual($"Processing directory: {relativeDirectory}", ProcessDirectoryPath(directoryPath)); } [TestMethod] public void InterfaceHierarchy_IsCorrect() { // Arrange - AbsoluteFilePath absoluteFilePath = AbsoluteFilePath.Create("C:\\test\\file.txt"); + AbsoluteFilePath absoluteFilePath = AbsoluteFilePath.Create(TestPaths.Absolute("test", "file.txt")); // Act & Assert - Test inheritance hierarchy Assert.IsTrue(absoluteFilePath is IAbsoluteFilePath, "Should be IAbsoluteFilePath"); @@ -294,7 +296,7 @@ public void TypeChecking_WithInterfaces_WorksCorrectly() // Arrange List paths = [ - AbsoluteFilePath.Create("C:\\file.txt"), + AbsoluteFilePath.Create(TestPaths.Absolute("file.txt")), DirectoryPath.Create("directory") ]; @@ -337,10 +339,10 @@ public void NonPathTypes_ImplementCorrectInterfaces() public void AsAbsolute_Method_WorksCorrectly() { // Arrange - Create different path types - AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create("C:\\test\\file.txt"); + AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create(TestPaths.Absolute("test", "file.txt")); FilePath genericFile = FilePath.Create("file.txt"); - AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create("C:\\test\\dir"); + AbsoluteDirectoryPath absoluteDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("test", "dir")); DirectoryPath genericDir = DirectoryPath.Create("dir"); // Act & Assert - Test file paths @@ -374,8 +376,8 @@ public void AsAbsolute_Method_WorksCorrectly() public void ConsolidatedPathConversions_API_WorksCorrectly() { // Arrange - AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create("C:\\base"); - AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create("C:\\base\\sub\\file.txt"); + AbsoluteDirectoryPath baseDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("base")); + AbsoluteFilePath absoluteFile = AbsoluteFilePath.Create(TestPaths.Absolute("base", "sub", "file.txt")); RelativeFilePath relativeFile = RelativeFilePath.Create("sub\\file.txt"); // Test the consolidated API @@ -386,26 +388,26 @@ public void ConsolidatedPathConversions_API_WorksCorrectly() // 2. AsAbsolute(baseDirectory) - convert to absolute using specific base AbsoluteFilePath absFromRelativeWithBase = relativeFile.AsAbsolute(baseDir); Assert.IsInstanceOfType(absFromRelativeWithBase); - Assert.Contains("C:\\base", absFromRelativeWithBase.WeakString); + Assert.Contains(TestPaths.Absolute("base"), absFromRelativeWithBase.WeakString); // 3. AsRelative(baseDirectory) - convert to relative using base RelativeFilePath relFromAbsolute = absoluteFile.AsRelative(baseDir); Assert.IsInstanceOfType(relFromAbsolute); - Assert.AreEqual("sub\\file.txt", relFromAbsolute.WeakString); + Assert.AreEqual(TestPaths.Relative("sub", "file.txt"), relFromAbsolute.WeakString); // 4. AsRelative(baseDirectory) on already relative path returns itself RelativeFilePath relFromRelative = relativeFile.AsRelative(baseDir); Assert.AreSame(relativeFile, relFromRelative); // Test with directory paths too - AbsoluteDirectoryPath absoluteSubDir = AbsoluteDirectoryPath.Create("C:\\base\\sub"); + AbsoluteDirectoryPath absoluteSubDir = AbsoluteDirectoryPath.Create(TestPaths.Absolute("base", "sub")); RelativeDirectoryPath relativeSubDir = RelativeDirectoryPath.Create("sub"); RelativeDirectoryPath relDirFromAbsolute = absoluteSubDir.AsRelative(baseDir); Assert.AreEqual("sub", relDirFromAbsolute.WeakString); AbsoluteDirectoryPath absDirFromRelative = relativeSubDir.AsAbsolute(baseDir); - Assert.Contains("C:\\base\\sub", absDirFromRelative.WeakString); + Assert.Contains(TestPaths.Absolute("base", "sub"), absDirFromRelative.WeakString); } [TestMethod] @@ -576,12 +578,12 @@ public void DirectoryPath_Contents_PolymorphicUsage() public void AllPathTypes_ImplicitStringConversion_WorksTransparently() { // Arrange - create instances of all path types - AbsolutePath absolutePath = AbsolutePath.Create("C:\\temp"); + AbsolutePath absolutePath = AbsolutePath.Create(TestPaths.Absolute("temp")); RelativePath relativePath = RelativePath.Create("relative\\path"); FilePath filePath = FilePath.Create("file.txt"); DirectoryPath directoryPath = DirectoryPath.Create("directory"); - AbsoluteFilePath absoluteFilePath = AbsoluteFilePath.Create("C:\\temp\\file.txt"); - AbsoluteDirectoryPath absoluteDirectoryPath = AbsoluteDirectoryPath.Create("C:\\temp\\directory"); + AbsoluteFilePath absoluteFilePath = AbsoluteFilePath.Create(TestPaths.Absolute("temp", "file.txt")); + AbsoluteDirectoryPath absoluteDirectoryPath = AbsoluteDirectoryPath.Create(TestPaths.Absolute("temp", "directory")); FileName fileName = FileName.Create("file.txt"); FileExtension fileExtension = FileExtension.Create(".txt"); @@ -595,12 +597,12 @@ public void AllPathTypes_ImplicitStringConversion_WorksTransparently() string result9 = fileName; string result10 = fileExtension; - Assert.AreEqual("C:\\temp", result1); + Assert.AreEqual(TestPaths.Absolute("temp"), result1); Assert.AreEqual("relative\\path", result2); Assert.AreEqual("file.txt", result3); Assert.AreEqual("directory", result4); - Assert.AreEqual("C:\\temp\\file.txt", result5); - Assert.AreEqual("C:\\temp\\directory", result7); + Assert.AreEqual(TestPaths.Absolute("temp", "file.txt"), result5); + Assert.AreEqual(TestPaths.Absolute("temp", "directory"), result7); Assert.AreEqual("file.txt", result9); Assert.AreEqual(".txt", result10); } @@ -609,7 +611,7 @@ public void AllPathTypes_ImplicitStringConversion_WorksTransparently() public void PathInterfaces_CanBeUsedInStringMethods() { // Arrange - IAbsoluteFilePath absoluteFilePath = AbsoluteFilePath.Create("C:\\temp\\test.txt"); + IAbsoluteFilePath absoluteFilePath = AbsoluteFilePath.Create(TestPaths.Absolute("temp", "test.txt")); IDirectoryPath directoryPath = DirectoryPath.Create("documents"); IFileName fileName = FileName.Create("readme.md"); @@ -620,7 +622,7 @@ public void PathInterfaces_CanBeUsedInStringMethods() string filename = Path.GetFileName(((AbsoluteFilePath)absoluteFilePath).ToString()); string combined = Path.Combine(((DirectoryPath)directoryPath).ToString(), ((FileName)fileName).ToString()); - Assert.AreEqual("C:\\temp", directory ?? string.Empty); + Assert.AreEqual(TestPaths.Absolute("temp"), directory ?? string.Empty); Assert.AreEqual("test.txt", filename); Assert.Contains("documents", combined); Assert.Contains("readme.md", combined); @@ -667,7 +669,7 @@ public void PathInterfaces_InPolymorphicCollections_CanBeUsedAsStrings() { // Arrange List paths = [ - AbsolutePath.Create("C:\\absolute"), + AbsolutePath.Create(TestPaths.Absolute("absolute")), RelativePath.Create("relative"), FilePath.Create("file.txt"), DirectoryPath.Create("directory") diff --git a/Semantics.Test/SemanticPathTests.cs b/Semantics.Test/SemanticPathTests.cs index 8666816e..3753678f 100644 --- a/Semantics.Test/SemanticPathTests.cs +++ b/Semantics.Test/SemanticPathTests.cs @@ -14,16 +14,16 @@ public class SemanticPathTests public void SemanticPath_BasicUsage() { // Test basic path creation and string conversion - AbsolutePath path = AbsolutePath.Create("C:\\test\\path"); + AbsolutePath path = AbsolutePath.Create(TestPaths.Absolute("test", "path")); Assert.IsNotNull(path); - Assert.AreEqual("C:\\test\\path", path.ToString()); + Assert.AreEqual(TestPaths.Absolute("test", "path"), path.ToString()); } [TestMethod] public void SemanticAbsolutePath_WithAbsolutePath_ShouldBeValid() { // Arrange & Act - AbsolutePath path = AbsolutePath.Create("C:\\test\\path"); + AbsolutePath path = AbsolutePath.Create(TestPaths.Absolute("test", "path")); // Assert Assert.IsTrue(path.IsValid(), "Absolute path should be valid"); @@ -52,7 +52,7 @@ public void SemanticRelativePath_WithAbsolutePath_ShouldThrowException() { // Arrange & Act & Assert Assert.ThrowsExactly(() => - RelativePath.Create("C:\\test\\path")); + RelativePath.Create(TestPaths.Absolute("test", "path"))); } [TestMethod] @@ -98,7 +98,7 @@ public void SemanticFilePath_FullFileExtension_MultipleExtensions_ShouldReturnAl public void SemanticFilePath_FileName_ShouldReturnCorrectFileName() { // Arrange - FilePath filePath = FilePath.Create("C:\\folder\\test.txt"); + FilePath filePath = FilePath.Create(TestPaths.Absolute("folder", "test.txt")); // Act FileName fileName = filePath.FileName; @@ -111,13 +111,13 @@ public void SemanticFilePath_FileName_ShouldReturnCorrectFileName() public void SemanticFilePath_DirectoryPath_ShouldReturnCorrectDirectory() { // Arrange - FilePath filePath = FilePath.Create("C:\\folder\\test.txt"); + FilePath filePath = FilePath.Create(TestPaths.Absolute("folder", "test.txt")); // Act DirectoryPath directoryPath = filePath.DirectoryPath; // Assert - Assert.AreEqual("C:\\folder", directoryPath.ToString()); + Assert.AreEqual(TestPaths.Absolute("folder"), directoryPath.ToString()); } [TestMethod] @@ -130,7 +130,13 @@ public void SemanticFileName_WithValidFileName_ShouldBeValid() Assert.IsTrue(fileName.IsValid(), "Valid file name should be valid"); } + /// + /// < and > are reserved on Windows and legal in a Unix filename. The + /// validator asks the running platform, so accepting them off Windows is correct. + /// [TestMethod] + [OSCondition(OperatingSystems.Windows)] + [TestCategory("OS-Specific")] public void SemanticFileName_WithInvalidChars_ShouldThrowException() { // Arrange & Act & Assert @@ -160,7 +166,8 @@ public void SemanticFileExtension_WithoutDot_ShouldThrowException() public void SemanticPath_NormalizePath() { // Test path normalization with mixed separators - AbsolutePath path = AbsolutePath.Create("C:/test\\path/"); + AbsolutePath path = AbsolutePath.Create( + TestPaths.AltAbsolute("test") + TestPaths.Separator + "path" + TestPaths.AlternateSeparator); Assert.IsNotNull(path); // Path should be normalized regardless of input format Assert.Contains("test", path.ToString()); @@ -171,18 +178,18 @@ public void SemanticPath_NormalizePath() public void SemanticPath_NonExistentPath() { // Test that non-existent paths can be created but marked appropriately - AbsolutePath path = AbsolutePath.Create("C:\\nonexistent\\path"); + AbsolutePath path = AbsolutePath.Create(TestPaths.Absolute("nonexistent", "path")); Assert.IsNotNull(path); - Assert.AreEqual("C:\\nonexistent\\path", path.ToString()); + Assert.AreEqual(TestPaths.Absolute("nonexistent", "path"), path.ToString()); } [TestMethod] public void SemanticPath_RootPath() { // Test root path creation - AbsolutePath rootPath = AbsolutePath.Create("C:\\"); + AbsolutePath rootPath = AbsolutePath.Create(TestPaths.Root); Assert.IsNotNull(rootPath); - Assert.AreEqual("C:\\", rootPath.ToString()); + Assert.AreEqual(TestPaths.Root, rootPath.ToString()); } [TestMethod] @@ -214,7 +221,7 @@ public void SemanticPath_PathTypes() Assert.IsTrue(relativePath.IsValid(), "Relative path should be valid"); // Test with absolute path - AbsolutePath absolutePath = AbsolutePath.Create("C:\\folder/subfolder\\file"); + AbsolutePath absolutePath = AbsolutePath.Create(TestPaths.Absolute("folder/subfolder", "file")); Assert.IsNotNull(absolutePath); Assert.IsTrue(absolutePath.IsValid(), "Absolute path should be valid"); } @@ -232,7 +239,7 @@ public void SemanticPath_EmptyPath_ShouldBeValid() public void SemanticPath_PathLength_Long() { // Test long but valid path - string longButValidPath = "C:\\" + string.Join("\\", Enumerable.Repeat("folder", 20)); + string longButValidPath = TestPaths.Absolute([.. Enumerable.Repeat("folder", 20)]); AbsolutePath path = AbsolutePath.Create(longButValidPath); Assert.IsNotNull(path); Assert.AreEqual(longButValidPath, path.ToString()); @@ -242,7 +249,8 @@ public void SemanticPath_PathLength_Long() public void SemanticPath_PathLength_TooLong() { // Test excessively long path (over typical OS limits) - string excessivelyLongPath = "C:\\" + string.Join("\\", Enumerable.Repeat("verylongfoldernamethatexceedstypicallimits", 50)); + string excessivelyLongPath = TestPaths.Absolute( + [.. Enumerable.Repeat("verylongfoldernamethatexceedstypicallimits", 50)]); Assert.ThrowsExactly(() => AbsolutePath.Create(excessivelyLongPath)); } @@ -251,8 +259,8 @@ public void SemanticPath_PathLength_TooLong() public void SemanticRelativePath_Make_ShouldCreateCorrectRelativePath() { // Arrange - AbsolutePath from = AbsolutePath.Create("C:\\base\\folder"); - AbsolutePath to = AbsolutePath.Create("C:\\base\\other\\file.txt"); + AbsolutePath from = AbsolutePath.Create(TestPaths.Absolute("base", "folder")); + AbsolutePath to = AbsolutePath.Create(TestPaths.Absolute("base", "other", "file.txt")); // Act RelativePath relativePath = RelativePath.Make(from, to); @@ -267,8 +275,8 @@ public void SemanticRelativePath_Make_ShouldCreateCorrectRelativePath() public void SemanticRelativePath_Make_WithDirectoryEndpoints_ShouldTreatThemAsDirectories() { // Arrange - string fromValue = OperatingSystem.IsWindows() ? "C:\\base\\folder" : "/base/folder"; - string toValue = OperatingSystem.IsWindows() ? "C:\\base\\other" : "/base/other"; + string fromValue = OperatingSystem.IsWindows() ? TestPaths.Absolute("base", "folder") : "/base/folder"; + string toValue = OperatingSystem.IsWindows() ? TestPaths.Absolute("base", "other") : "/base/other"; AbsoluteDirectoryPath from = AbsoluteDirectoryPath.Create(fromValue); AbsoluteDirectoryPath to = AbsoluteDirectoryPath.Create(toValue); @@ -285,8 +293,8 @@ public void SemanticRelativePath_Make_WithDirectoryEndpoints_ShouldTreatThemAsDi public void SemanticRelativePath_Make_FromDirectoryToContainedFile_ShouldNotStepOutOfTheDirectory() { // Arrange - string fromValue = OperatingSystem.IsWindows() ? "C:\\base\\folder" : "/base/folder"; - string toValue = OperatingSystem.IsWindows() ? "C:\\base\\folder\\file.txt" : "/base/folder/file.txt"; + string fromValue = OperatingSystem.IsWindows() ? TestPaths.Absolute("base", "folder") : "/base/folder"; + string toValue = OperatingSystem.IsWindows() ? TestPaths.Absolute("base", "folder", "file.txt") : "/base/folder/file.txt"; AbsoluteDirectoryPath from = AbsoluteDirectoryPath.Create(fromValue); AbsoluteFilePath to = AbsoluteFilePath.Create(toValue); @@ -303,27 +311,18 @@ public void SemanticRelativePath_Make_FromDirectoryToContainedFile_ShouldNotStep [TestMethod] public void SemanticPath_MakeCanonical_WithRootPath_ShouldPreserveTrailingSeparator() { - // This test checks that root paths like "C:\" keep their trailing separator - if (OperatingSystem.IsWindows()) - { - // On Windows, test root drive paths - AbsolutePath rootPath = AbsolutePath.Create("C:\\"); - Assert.AreEqual("C:\\", rootPath.ToString()); - } - else - { - // On Unix-like systems, test root path - AbsolutePath rootPath = AbsolutePath.Create("/"); - Assert.AreEqual("/", rootPath.ToString()); - } + // This test checks that a root path (C:\ on Windows, / elsewhere) keeps its trailing separator. + AbsolutePath rootPath = AbsolutePath.Create(TestPaths.Root); + Assert.AreEqual(TestPaths.Root, rootPath.ToString()); } [TestMethod] public void SemanticPath_MakeCanonical_WithMixedSeparators_ShouldNormalize() { // Test path with mixed separators - use absolute path - AbsolutePath path = AbsolutePath.Create("C:/folder/subfolder\\file"); - string expected = "C:" + Path.DirectorySeparatorChar + "folder" + Path.DirectorySeparatorChar + "subfolder" + Path.DirectorySeparatorChar + "file"; + AbsolutePath path = AbsolutePath.Create( + TestPaths.AltAbsolute("folder", "subfolder") + TestPaths.Separator + "file"); + string expected = TestPaths.Absolute("folder", "subfolder", "file"); Assert.AreEqual(expected, path.ToString()); } @@ -349,7 +348,7 @@ public void SemanticFilePath_FullFileExtension_WithSingleExtension_ShouldReturnS public void SemanticFilePath_FileName_WithPathSeparators_ShouldReturnOnlyFileName() { // Test filename extraction from complex paths - FilePath filePath = FilePath.Create("C:\\very\\deep\\folder\\structure\\document.docx"); + FilePath filePath = FilePath.Create(TestPaths.Absolute("very", "deep", "folder", "structure", "document.docx")); FileName fileName = filePath.FileName; Assert.AreEqual("document.docx", fileName.ToString()); } @@ -358,12 +357,9 @@ public void SemanticFilePath_FileName_WithPathSeparators_ShouldReturnOnlyFileNam public void SemanticFilePath_DirectoryPath_WithRootFile_ShouldReturnRootDirectory() { // Test directory extraction when file is in root - if (OperatingSystem.IsWindows()) - { - FilePath filePath = FilePath.Create("C:\\file.txt"); - DirectoryPath directoryPath = filePath.DirectoryPath; - Assert.AreEqual("C:\\", directoryPath.ToString()); - } + FilePath filePath = FilePath.Create(TestPaths.Absolute("file.txt")); + DirectoryPath directoryPath = filePath.DirectoryPath; + Assert.AreEqual(TestPaths.Root, directoryPath.ToString()); } [TestMethod] @@ -379,7 +375,7 @@ public void SemanticFilePath_DirectoryPath_WithEmptyResult_ShouldReturnEmpty() public void SemanticRelativePath_Make_WithNullArguments_ShouldThrowArgumentNullException() { // Test null argument handling - AbsolutePath validPath = AbsolutePath.Create("C:\\test"); + AbsolutePath validPath = AbsolutePath.Create(TestPaths.Absolute("test")); Assert.ThrowsExactly(() => RelativePath.Make(null!, validPath)); @@ -392,8 +388,8 @@ public void SemanticRelativePath_Make_WithNullArguments_ShouldThrowArgumentNullE public void SemanticRelativePath_Make_WithDirectoryPaths_ShouldHandleCorrectly() { // Test relative path creation between directories - AbsolutePath from = AbsolutePath.Create("C:\\base\\folder1"); - AbsolutePath to = AbsolutePath.Create("C:\\base\\folder2"); + AbsolutePath from = AbsolutePath.Create(TestPaths.Absolute("base", "folder1")); + AbsolutePath to = AbsolutePath.Create(TestPaths.Absolute("base", "folder2")); RelativePath relativePath = RelativePath.Make(from, to); Assert.IsNotNull(relativePath); diff --git a/Semantics.Test/TestPaths.cs b/Semantics.Test/TestPaths.cs new file mode 100644 index 00000000..5d93901c --- /dev/null +++ b/Semantics.Test/TestPaths.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test; + +using System; +using System.IO; + +/// +/// Builds path fixtures that mean the same thing on every platform. +/// +/// +/// +/// The path tests were written against Windows and hard-coded its spelling of everything: C:\ +/// for the root, \ for the separator. None of that is portable. On Linux C:\projects +/// is a relative path whose first segment happens to contain a colon and a backslash, so +/// AbsoluteDirectoryPath.Create rejects it — correctly — and the test fails for a reason that +/// has nothing to do with what it was written to check. +/// +/// +/// Composing fixtures here instead keeps each test asserting the thing it was about. The library +/// itself is already platform-correct: it takes its separators from , and treats +/// \ as an ordinary filename character on platforms where that is what it is. +/// +/// +internal static class TestPaths +{ + /// + /// Gets the platform's absolute-path root — C:\ on Windows, / everywhere else. + /// + internal static string Root { get; } = OperatingSystem.IsWindows() ? "C:" + Separator : "/"; + + /// Gets the platform's directory separator. + internal static char Separator => Path.DirectorySeparatorChar; + + /// + /// Gets the platform's alternate directory separator. + /// + /// + /// On Windows this is /, distinct from . On Unix both are /: + /// there is no second spelling, because the other candidate is a legal filename character. A + /// "mixed separators" fixture built from both therefore stays a genuine mixed-separator case on + /// Windows and degrades to an ordinary path on Unix, which is the honest translation — rather + /// than one that smuggles a backslash in and tests something else entirely. + /// + internal static char AlternateSeparator => Path.AltDirectorySeparatorChar; + + /// + /// Joins segments into an absolute path rooted at . + /// + /// The path segments, in order. + /// An absolute path this platform recognises as fully qualified. + internal static string Absolute(params string[] segments) => Root + Relative(segments); + + /// + /// Joins segments into a relative path using the platform separator. + /// + /// The path segments, in order. + /// A relative path. + internal static string Relative(params string[] segments) => + string.Join(Separator.ToString(), segments); + + /// + /// Joins segments using , for fixtures that are specifically + /// about a path written the other way round. + /// + /// The path segments, in order. + /// A relative path spelled with the alternate separator. + internal static string AltRelative(params string[] segments) => + string.Join(AlternateSeparator.ToString(), segments); + + /// + /// Joins segments into an absolute path spelled with . + /// + /// The path segments, in order. + /// An absolute path spelled with the alternate separator. + internal static string AltAbsolute(params string[] segments) => + Root.Replace(Separator, AlternateSeparator) + AltRelative(segments); +}