From 16d5de45642b0f914e060fd0a4ea328f76dd574d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:27:25 +0000 Subject: [PATCH 1/6] fix: point SEM001 and SEM003 at the metadata they are complaining about [patch] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEM004 was given a source location in #182; SEM001 and SEM003 were left reporting at Location.None because the collectors that raise them never received the MetadataFile. dimensions.json is thousands of lines, and both are typo-catchers, so "which name is wrong" without "where it is written" leaves the reader to do most of the work themselves. Thread MetadataFile? through CollectAllOperators/CollectAllProducts and their four collectors, and through ResolveForms, then report through ReportAt. The two diagnostics want different searches, which is why this is not one call: SEM001 names something that does not exist anywhere, so the first occurrence of it in the file is the mistake. The existing unscoped FindLocation is exactly right. SEM003 names dimensions that all exist and are spelled correctly. Searching for one unscoped lands on its first legitimate mention, which is somewhere else entirely — technically a location, practically a wrong answer. So FindLocation gains a scoped overload: find the owning dimension's own "name" property, then find the relationship's "other" at or after it. It falls back to the unscoped search, and then to Location.None, so a metadata layout it does not recognise degrades to what was there before rather than to nothing. GeneratorDiagnosticTests gains an AssertPointsAt helper that reads the covered text back out of the metadata, and location tests for SEM001 and SEM003 built on it. Sem004_PointsAtWhereTheUnitIsWrittenRatherThanAtNothing moves onto the same helper, which strengthens it: it asserted only that the location was not Location.None, which would also have passed for a location pointing at the wrong entry — the failure mode this change is specifically about. Closes #193 Co-Authored-By: Claude --- .../CodeGen/MetadataFile.cs | 39 ++++++ .../Generators/QuantitiesGenerator.cs | 120 ++++++++++++------ .../Quantities/GeneratorDiagnosticTests.cs | 67 ++++++++-- 3 files changed, 179 insertions(+), 47 deletions(-) diff --git a/Semantics.SourceGenerators/CodeGen/MetadataFile.cs b/Semantics.SourceGenerators/CodeGen/MetadataFile.cs index a7824a6..6f8fc2c 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/QuantitiesGenerator.cs b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs index fe5301a..bd07bdb 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; } @@ -241,7 +246,8 @@ private static void CollectIntegralOperators( [0, 1, 2, 3, 4], dim, resultDim, - $"integrals[{integral.Other} -> {integral.Result}]"); + $"integrals[{integral.Other} -> {integral.Result}]", + dimensionsFile); foreach (int vn in forms) { AddIntegralOpsForForm(result, seen, dim, resultDim, vn, v0Other); @@ -285,19 +291,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; } @@ -313,7 +320,8 @@ private static void CollectDerivativeOperators( [0, 1, 2, 3, 4], dim, resultDim, - $"derivatives[{derivative.Other} -> {derivative.Result}]"); + $"derivatives[{derivative.Other} -> {derivative.Result}]", + dimensionsFile); foreach (int vn in forms) { AddDerivativeOpsForForm(result, seen, dim, resultDim, vn, v0Other); @@ -344,15 +352,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 +378,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; } @@ -395,7 +408,8 @@ private static void CollectDotProducts( [1, 2, 3, 4], dim, otherDim, - $"dotProducts[{dot.Other} -> {dot.Result}]"); + $"dotProducts[{dot.Other} -> {dot.Result}]", + dimensionsFile); foreach (int vn in forms) { AddDotProductForForm(result, seen, dim, otherDim, vn, v0Result); @@ -433,19 +447,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; } @@ -460,6 +475,7 @@ private static void CollectCrossProducts( dim, otherDim, $"crossProducts[{cross.Other} -> {cross.Result}]", + dimensionsFile, resultDim); if (Array.IndexOf(forms, 3) < 0) { @@ -507,15 +523,26 @@ 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); /// /// Resolves the forms at which a relationship should emit operators. When the metadata @@ -532,6 +559,7 @@ private static int[] ResolveForms( PhysicalDimension dim, PhysicalDimension otherDim, string fieldPath, + MetadataFile? dimensionsFile, PhysicalDimension? resultDim = null) { if (rel.Forms.Count == 0) @@ -549,19 +577,19 @@ private static int[] ResolveForms( if (GetBaseTypeName(dim, form) == null) { - ReportFormMissing(context, dim.Name, fieldPath, form, dim.Name); + ReportFormMissing(context, dimensionsFile, dim, rel, fieldPath, form, dim.Name); continue; } if (GetBaseTypeName(otherDim, form) == null) { - ReportFormMissing(context, dim.Name, fieldPath, form, otherDim.Name); + ReportFormMissing(context, dimensionsFile, dim, rel, fieldPath, form, otherDim.Name); continue; } if (resultDim != null && GetBaseTypeName(resultDim, form) == null) { - ReportFormMissing(context, dim.Name, fieldPath, form, resultDim.Name); + ReportFormMissing(context, dimensionsFile, dim, rel, fieldPath, form, resultDim.Name); continue; } @@ -571,16 +599,30 @@ 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( + SourceProductionContext context, + MetadataFile? dimensionsFile, + PhysicalDimension owningDimension, + RelationshipDefinition rel, + string fieldPath, + int form, + string offendingDimension) => + context.ReportAt( SemanticsDiagnostics.RelationshipFormMissing, - Location.None, - owningDimension, + dimensionsFile?.FindLocation($"\"name\": \"{owningDimension.Name}\"", $"\"other\": \"{rel.Other}\""), + owningDimension.Name, fieldPath, form, - offendingDimension)); - } + offendingDimension); private static Dictionary BuildUnitMap(UnitsMetadata units) { diff --git a/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs b/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs index 8dccde9..0edcd9e 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; @@ -55,6 +56,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 +98,15 @@ 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"); + } + [TestMethod] public void Sem002_IsReportedForADimensionMissingItsSymbol() { @@ -93,6 +136,21 @@ 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\""); + } + [TestMethod] public void Sem004_IsReportedForAUnitThatUnitsJsonDoesNotDeclare() { @@ -106,14 +164,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] From dfde92b8d10efcd07104f6301eb605e79c7249c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:27:40 +0000 Subject: [PATCH 2/6] build: silence RS2002 for the generator project [patch] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every build emitted one RS2002 per SEM diagnostic — seven of them since #182 added SEM006 and SEM007. The release-tracking analyzer only sees descriptors exposed through DiagnosticAnalyzer.SupportedDiagnostics. These are reported by source generators, which have no such property, so it can never see them however they are declared. That left the project stuck between its own rules: omit the identifiers from AnalyzerReleases.Unshipped.md and RS2008 fails the build; include them and RS2002 warns because nothing claims them. Keep the identifiers tracked and silence RS2002, scoped to Semantics.SourceGenerators.csproj with a comment saying why the rule cannot be satisfied here. AnalyzerReleaseTrackingTests already enforces what RS2008 enforces, from a vantage point that can actually see the descriptors, so the discipline survives the suppression. Verified: a clean build of the project now reports 0 warnings. Closes #194 Co-Authored-By: Claude --- .../Semantics.SourceGenerators.csproj | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj b/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj index 496aa15..8c5c787 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 From 51ffa898600f0d8829b28c40dd3e9594af77d8b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:27:40 +0000 Subject: [PATCH 3/6] docs: make the local Sonar reproduction reach every project [patch] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md documented the reproduction along with a limitation: it reached only Semantics.SourceGenerators, the one project declaring its SDK with the attribute form. The five ktsu.Sdk projects use with elements, which CustomBeforeMicrosoftCommonProps does not reach — so five of six projects had no local coverage and their findings only surfaced after a push. CustomAfterMicrosoftCommonProps does reach that form, and is still early enough for restore to pick up the analyzer PackageReference. Switch the documented invocation and the comment in sonar-local.props, and replace the limitation paragraph with the caveat that actually applies: the globalconfig was calibrated against this repository's profile, so a clean local run is strong evidence rather than proof. Verified by building with the After hook and seeing Sonar findings reported against Semantics.Color and Semantics.Music, neither of which the Before hook reached at all. The first wider run surfaced one finding, in Semantics.Color: S1244 against the exact zero check in Oklab.Cbrt. The comparison is correct — the Newton-Raphson step divides by x * x, and x is exactly zero only when value is, so a tolerance band would send small-but-valid inputs down the shortcut and lose the precision the refinement exists to recover. Suppressed at the method with that justification rather than reworked. Closes #195 Co-Authored-By: Claude --- .sonarlint/sonar-local.props | 8 +++++++- CLAUDE.md | 8 +++++--- Semantics.Color/Oklab.cs | 5 +++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.sonarlint/sonar-local.props b/.sonarlint/sonar-local.props index 578372c..c803346 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 2ac9e38..17ee7f0 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 0c8769c..3c9a5c7 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) From 87087ffd16a6101ac8c6ee2c9e37dbd22b7d545e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:41:45 +0000 Subject: [PATCH 4/6] test: make the path tests run on Linux [patch] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Test on ubuntu-latest` has been red on main since 8b9df02 added Linux to the matrix, with 92 failures — every one in the path tests. The new coverage found what it was added to find on its first run: these tests had only ever been exercised on Windows, and had its spelling of everything baked in. All 92 turned out to be test assumptions rather than Semantics.Paths defects. The library takes its separators from System.IO.Path and treats a backslash as an ordinary filename character where that is what it is, which is correct; the tests asserted the Windows answer and got the right one for the wrong platform. TestPaths composes fixtures from Path.DirectorySeparatorChar and a platform-appropriate root, so `TestPaths.Absolute("projects", "app")` is `C:\projects\app` on Windows and `/projects/app` elsewhere. Rendering every converted fixture back into its Windows spelling reproduces the original string literal exactly, so Windows behaviour is unchanged by construction — worth stating because a Linux-green suite proves nothing about the platform the tests came from. Mixed-separator fixtures use Path.AltDirectorySeparatorChar rather than a hard-coded backslash. On Unix both separators are '/', so those cases degrade to ordinary paths rather than smuggling in a backslash and testing something else. Eleven were not fixture spelling and needed a decision: - UNC is a Windows concept; \\server\share is an ordinary relative filename on Unix. [OSCondition(OperatingSystems.Windows)] + [TestCategory("OS-Specific")], matching Parent_OfUncShareRoot_ReturnsTheShareItself. - < > : | are reserved on Windows and legal in a Unix filename. The validators ask the running platform via Path.GetInvalidFileNameChars, so accepting them off Windows is the correct answer, not a gap. Same gating, with the reason on each test. - "rejects path separators" tests now build the separator from Path rather than hard-coding a backslash, which makes them assert the thing they are named for on both platforms instead of only one. AbsoluteDirectoryPathParentTests was already platform-correct via a per-platform conditional; TestPaths makes one expression cover both, so the conditional goes. Result on Linux: 1110 total, 0 failed, 1102 passed, 8 skipped — the 8 being the Windows-only cases above. Closes #191 Co-Authored-By: Claude --- Semantics.Test/ErrorHandlingTests.cs | 4 +- .../PathValidationAttributeTests.cs | 54 +++++++++--- .../Paths/AbsoluteDirectoryPathParentTests.cs | 10 ++- Semantics.Test/Paths/DirectoryNameTests.cs | 18 +++- Semantics.Test/Paths/PathConversionTests.cs | 52 +++++------ Semantics.Test/Paths/PathIntegrationTests.cs | 38 ++++---- Semantics.Test/Paths/PathOperatorTests.cs | 26 +++--- Semantics.Test/Paths/PathUtilityTests.cs | 53 +++++------ .../Paths/RelativePathPropertyTests.cs | 30 +++---- .../Paths/SemanticPathInterfaceTests.cs | 6 +- Semantics.Test/SemanticPathInterfaceTests.cs | 78 ++++++++-------- Semantics.Test/SemanticPathTests.cs | 88 +++++++++---------- Semantics.Test/TestPaths.cs | 78 ++++++++++++++++ 13 files changed, 329 insertions(+), 206 deletions(-) create mode 100644 Semantics.Test/TestPaths.cs diff --git a/Semantics.Test/ErrorHandlingTests.cs b/Semantics.Test/ErrorHandlingTests.cs index a1bedc4..d1a8922 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 8f7fb47..e2e3826 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 fbc91f6..86b04cb 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 eb4e4b0..755fb20 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 3e7c8b4..4f334cf 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 7ba05ed..9bef792 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 b61188a..4ca5068 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 188ecf1..6829637 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 5c78d90..30c50f2 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 bb98554..292f0a1 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/SemanticPathInterfaceTests.cs b/Semantics.Test/SemanticPathInterfaceTests.cs index 6a05c0b..800ba70 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 8666816..3753678 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 0000000..5d93901 --- /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); +} From a5ce06259648d3107984c4001834a6a1b5b1ec49 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:41:57 +0000 Subject: [PATCH 5/6] refactor: clear the Sonar findings the wider analyzer reach exposed [patch] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, two of them only visible because the local reproduction now reaches every project. S107 on ResolveForms is mine: threading MetadataFile? through for #193 pushed it to eight parameters. The relationship, the file it came from, the dimension that owns it and its field path all travel together and only exist to let a diagnostic say where it is, so they become one RelationshipSite. ResolveForms drops to four parameters and ReportFormMissing to three, and the call sites read better than the seven-argument version they replace. S1244 on NormalizedParameter.Logarithmic is pre-existing, newly visible. The exact zero check is correct — a logarithmic range is undefined at zero but perfectly well defined at 1e-9, so a tolerance band would reject legitimate ranges. Comparing Math.Sign results says the same thing without comparing floats, so this needs no suppression. S3267 on DimensionsGenerator is pre-existing: the loop only ever reads dimension.Name, so it iterates the names. Verified: the whole solution now builds clean under the Sonar analyzers except for a pre-existing backlog in Semantics.Test (S4144 on identical test bodies, S101 on SSNString), which is untouched by this branch and is its own triage. Generated output is unchanged — verified by rebuilding Semantics.Quantities and diffing Generated/. Co-Authored-By: Claude --- .../AudioEngineering/NormalizedParameter.cs | 9 +- .../Generators/DimensionsGenerator.cs | 6 +- .../Generators/QuantitiesGenerator.cs | 85 +++++++++---------- 3 files changed, 48 insertions(+), 52 deletions(-) diff --git a/Semantics.Quantities/AudioEngineering/NormalizedParameter.cs b/Semantics.Quantities/AudioEngineering/NormalizedParameter.cs index 0096e36..c09a922 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/Generators/DimensionsGenerator.cs b/Semantics.SourceGenerators/Generators/DimensionsGenerator.cs index 252fe59..9897ace 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 bd07bdb..5e6dce3 100644 --- a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs +++ b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs @@ -241,13 +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}]", - dimensionsFile); + resultDim); foreach (int vn in forms) { AddIntegralOpsForForm(result, seen, dim, resultDim, vn, v0Other); @@ -315,13 +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}]", - dimensionsFile); + resultDim); foreach (int vn in forms) { AddDerivativeOpsForForm(result, seen, dim, resultDim, vn, v0Other); @@ -403,13 +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}]", - dimensionsFile); + otherDim); foreach (int vn in forms) { AddDotProductForForm(result, seen, dim, otherDim, vn, v0Result); @@ -469,13 +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}]", - dimensionsFile, resultDim); if (Array.IndexOf(forms, 3) < 0) { @@ -544,6 +528,26 @@ private static void ReportUnknownReference( unknownReference, 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 /// declares explicitly, that list wins and @@ -553,43 +557,39 @@ private static void ReportUnknownReference( /// 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, - MetadataFile? dimensionsFile, 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, dimensionsFile, dim, rel, fieldPath, form, dim.Name); + ReportFormMissing(site, form, site.Owner.Name); continue; } if (GetBaseTypeName(otherDim, form) == null) { - ReportFormMissing(context, dimensionsFile, dim, rel, fieldPath, form, otherDim.Name); + ReportFormMissing(site, form, otherDim.Name); continue; } if (resultDim != null && GetBaseTypeName(resultDim, form) == null) { - ReportFormMissing(context, dimensionsFile, dim, rel, fieldPath, form, resultDim.Name); + ReportFormMissing(site, form, resultDim.Name); continue; } @@ -608,19 +608,12 @@ private static int[] ResolveForms( /// 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( - SourceProductionContext context, - MetadataFile? dimensionsFile, - PhysicalDimension owningDimension, - RelationshipDefinition rel, - string fieldPath, - int form, - string offendingDimension) => - context.ReportAt( + private static void ReportFormMissing(RelationshipSite site, int form, string offendingDimension) => + site.Context.ReportAt( SemanticsDiagnostics.RelationshipFormMissing, - dimensionsFile?.FindLocation($"\"name\": \"{owningDimension.Name}\"", $"\"other\": \"{rel.Other}\""), - owningDimension.Name, - fieldPath, + site.File?.FindLocation($"\"name\": \"{site.Owner.Name}\"", $"\"other\": \"{site.Relationship.Other}\""), + site.Owner.Name, + site.FieldPath, form, offendingDimension); From c5a4986ee73d0c9ad815a654236a27dc0da99c78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:07:40 +0000 Subject: [PATCH 6/6] test: cover the diagnostic sites and location fallbacks this PR touched [patch] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud failed the quality gate on #197: 66.7% coverage on new code against a required 80%. Measuring the same thing locally (cobertura, intersected with the lines the branch adds) put it at 76.5% and named the same two files. Every gap was a real one, and the tests are real rather than coverage padding: QuantitiesGenerator — the eight SEM001 report sites are one per relationship kind per field, each with its own field-path message, and only integrals[...].other had a test. A rename that dropped any of the other seven would not have failed anything. Now a DataRow per kind and field, asserting both the location and the field path in the message. SEM003 had only its self branch covered. The other two participants are the interesting ones — a cross product needs the form on its other operand and on its result, and the diagnostic has to name whichever is missing it rather than the dimension that owns the relationship. Two tests, using a second dimension that declares vector0 only. MetadataFile — the scoped FindLocation overload had its happy path covered through SEM003 and none of its fallbacks, which are the whole reason it is safe to use: an unrecognised metadata layout has to degrade to the unscoped search and then to Location.None rather than throw or point somewhere arbitrary. MetadataFileTests covers both overloads directly, including a null SourceText. New-code coverage measured the same way afterwards: 52/52 lines, 100%. 1127 tests, 0 failures. The Sonar analyzers report nothing new — the remaining findings are the pre-existing Semantics.Test backlog (S4144, S101). Co-Authored-By: Claude --- .../Quantities/GeneratorDiagnosticTests.cs | 102 +++++++++++++++++ .../Quantities/MetadataFileTests.cs | 105 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 Semantics.Test/Quantities/MetadataFileTests.cs diff --git a/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs b/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs index 0edcd9e..50a20a0 100644 --- a/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs +++ b/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs @@ -46,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]; @@ -107,6 +140,41 @@ public void Sem001_PointsAtTheMisspelledNameRatherThanAtNothing() 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() { @@ -151,6 +219,40 @@ public void Sem003_PointsAtTheRelationshipRatherThanAtNothing() "\"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() { diff --git a/Semantics.Test/Quantities/MetadataFileTests.cs b/Semantics.Test/Quantities/MetadataFileTests.cs new file mode 100644 index 0000000..89d4e35 --- /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")); + } +}