diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa2073..6be9095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Fixes +- Issue #28: IgnoreCase rejects multiple case-insensitive source matches even when one is exact; nested structural ambiguity retains LITEMAPPER1004. Exact and ExactThenIgnoreCase keep their existing exact-match priority. [#28](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/28) - Issue #27: by-name enum exceptions use stable source names and paths instead of applying `nameof` to guarded expressions; nullable enum captures retain source metadata. [#27](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/27) - Issue #26: structural collection mapping preserves nullable elements and applies Throw guards before object helpers; nullable nested collection helper signatures retain their annotations. [#26](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/26) - Issue #25: cycle detection now forwards one context through acyclic object and collection bridges, retaining complete member paths without tracking the bridge objects. [#25](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/25) diff --git a/DECISIONS.md b/DECISIONS.md index 8810ce2..bc6b299 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Decision Log +## Issue #28: apply the selected matching policy before exact priority (2026-10-09) + +- Context: MatchSource selected a sole exact candidate even under IgnoreCase. Constructor matching already limits exact priority to Exact and ExactThenIgnoreCase. Structural helper planning also discarded an ambiguous match without reporting its specific member diagnostic. +- Decision: use the same exact-priority gate in member matching and report LITEMAPPER1004 at the nested target member before rejecting the structural plan. Preserve discovery, hidden-member resolution, explicit configuration, constructor matching, and method failure isolation. +- Contract: sections 6.5, 9.1-9.3, 10.3, 20.2-20.3, and 28.2 already require unique IgnoreCase matches and non-configurable ambiguity errors. No public API or specification semantics change is required. +- Evidence: 40 new generator cases on unchanged production yielded 22 failures and 18 controls, zero skips. Cases cover creation, updates, nested mappings, properties/fields, inherited class/interface members, option precedence, exact/unique controls, explicit paths, hiding, diagnostic location, and independent-method generation. + ## Issue #27: separate enum exception metadata from executable expressions (2026-10-09) - Context: configured nullable traversal renders coalescing/null-conditional expressions that are invalid nameof operands. Nullable enum conversion replaces source expressions with generated captures, leaking private names into exception metadata. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 394f8e6..46817df 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #28: IgnoreCase member ambiguity (2026-10-09) + +- Scope: apply unique ordinal-ignore-case matching without exact-match priority to automatic source members. Preserve Exact and ExactThenIgnoreCase, constructor matching, hidden-member selection, and explicit configuration precedence. +- Test-first: cover new-object, update, nested creation/update, properties, fields, class/interface inheritance, diagnostics and omitted invalid implementations. Retain constructor and successful exact/unique-match controls. +- Implementation: align the shared member matcher with the existing constructor matcher; preserve the specific ambiguity diagnostic when structural helper planning fails. +- Validation: focused/full generator, runtime/integration, source samples, available package/consumer/API/benchmark checks, independent review, and exact-head ordinary Windows/Linux and Roslyn CI. +- Boundary: issue #28 only, no public API or specification semantics change. No merge, release, tag, package publication, or workflow dispatch. Stop at draft PR/human review. AgentStack CLI is absent; authorized connector fallback adds/readbacks readiness labels without issue comments or claim writes. + ## Issue #27: stable enum exception names (2026-10-09) - Scope: separate by-name enum exception metadata from rendered value expressions. Preserve root parameter/direct source-member names; configured source paths remain identifiable even after null guards or captures. diff --git a/STATUS.md b/STATUS.md index c2fd824..6fe1c0f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,15 @@ # Mammoth.LiteMapper Status +## GitHub issue #28 IgnoreCase ambiguity checkpoint (2026-10-09) + +- Base: isolated cloud worktree from develop 14eaa974769554884d7f8ca71ff8821a6e19470f after PR #41 merged. Issue #28 was the oldest remaining P2, with no blocking dependencies, sub-issues, or competing PRs. Authorized exec:agent and ready:agent labels were added and read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes. +- Red-first: final 40-case generator fixture yielded 22 failures and 18 controls on unchanged production, zero skips. An initial fixture also included two independently unsupported nested-interface cases; these were removed and replaced with constructor exact-policy controls before the final baseline. Four clean-package cases independently demonstrated successful compilation of ambiguous mappings on netstandard2.0/net8.0/net9.0/net10.0. A subsequent red run isolating direct/nested mapper containers was interrupted by automatic approval cancellation and produced no final TRX. +- Implementation: restrict member-matching exact priority to Exact and ExactThenIgnoreCase; preserve LITEMAPPER1004 at the nested target member when structural planning encounters ambiguity. Constructor matching, explicit configuration, hidden-member selection, failure isolation, and public API remain unchanged. Usage, consumer guidance, and changelog are synchronized. +- Verified core evidence: Release solution build has zero warnings/errors. All 799 generator tests pass, including 40 new cases; runtime five and integration one pass, zero failures/skips. The focused new/constructor/inheritance/root-converter set passes 94/94. C# 9 fixtures assert diagnostic severity/location, omitted invalid methods, retained independent methods, deterministic output, and runtime exact/unique-match controls. Independent read-only review, consumer-skill validation, and CRLF-aware diff checks pass. +- Verified package evidence: 224 passes, two infrastructure failures, zero skips in the available packaging suite. All four final isolated package ambiguity cases pass, rejecting invalid direct/nested mappings without generated implementations. All 12 successful framework/language combinations, clean-package samples, source samples/usage, package/API checks, and benchmark smoke pass. +- Local limits: trimming and Native AOT fail at ILLink ComputeManagedAssemblies task-host creation with MSB4216 / Unix-domain SocketException 13, before publish behavior is established. PowerShell and clang are absent; 49 PowerShell-dependent cases were not run locally. Workspace-local temporary files avoid the previous small-temp-filesystem failure. No warning, assertion, dependency, prerequisite, credential, or security setting was weakened. Exact-head ordinary Windows/Linux and Roslyn CI evidence belongs in the draft PR. +- Boundary: issue #28 only, then human review. The previously documented nullable-intermediate enum runtime defect and other issues remain outside scope. No user-computer access, merge, release, tag push, package publication, or workflow dispatch. + ## GitHub issue #27 enum diagnostic-name checkpoint (2026-10-09) - Base: isolated cloud worktree from develop 8d41533957c7b5b27943327c8b6b3eef171b4232 after PR #40 merged. Issue #27 was the oldest remaining P2; no blocked-by dependencies, sub-issues, or competing PRs. Authorized exec:agent and ready:agent labels were added and read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes. diff --git a/docs/USAGE.md b/docs/USAGE.md index 466eab4..ab29ab2 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -230,9 +230,11 @@ Useful options: - `NameMatching.Exact`: ordinal exact member names only. - `NameMatching.ExactThenIgnoreCase`: exact first, then unique ignore-case match. -- `NameMatching.IgnoreCase`: unique ignore-case match. +- `NameMatching.IgnoreCase`: unique ordinal ignore-case match, without exact-match priority. - `UnmappedMemberPolicy.Ignore`, `Info`, `Warning`, `Error`: controls diagnostics for unmapped source or target members. +If a source exposes both `Value` and `value`, automatic matching to target `Value` under `IgnoreCase` reports `LITEMAPPER1004` and omits the invalid implementation, including in updates and nested structural mappings. `Exact` and `ExactThenIgnoreCase` select the exact `Value` member. An explicit `MapProperty.Source` can select the intended member. Constructor parameters follow the same matching policy. + Member candidates are public instance properties and fields. Hidden members are resolved to the most-derived usable member, with property-over-field preference when needed. Source paths in `[MapProperty]` may use dotted member paths such as `"Email.Value"`. Source paths must resolve to members and cannot use method-call syntax. Source methods are not discovered automatically; use a converter or root-source `MapProperty.Use` method when a method-derived value is needed. diff --git a/skills/mammoth-litemapper/references/mapping-rules.md b/skills/mammoth-litemapper/references/mapping-rules.md index 4ce4882..bfb5f9f 100644 --- a/skills/mammoth-litemapper/references/mapping-rules.md +++ b/skills/mammoth-litemapper/references/mapping-rules.md @@ -83,6 +83,8 @@ Options resolve method `[MappingOptions]`, mapper `[LiteMapper]`, assembly `[Lit For an opt-in strict source-completeness boundary, set `UnmappedSourceMembers` to `Error` at mapper, method, or assembly scope. This reports unused readable source members as configurable `LITEMAPPER1003` diagnostics naming the member; use `Warning` or `Info` to stage adoption and `IgnoreSource` for intentional exclusions. The default remains `Ignore` because source models can contain fields that are not part of a given destination contract. Source completeness does not weaken target completeness or mandatory target errors. +Name matching uses ordinal comparisons. `Exact` accepts only an exact name; `ExactThenIgnoreCase` prefers exact and otherwise requires a unique case-insensitive match. `IgnoreCase` requires a unique case-insensitive match without exact priority: source `Value` plus `value` targeting `Value` reports non-configurable `LITEMAPPER1004`, including in updates and nested structural mapping. Explicit `MapProperty.Source` can disambiguate. Constructor parameters follow the same policy; hidden members are resolved before matching. + ## Nulls and updates - Nullable-to-non-null values default to a compile-time error. `NullableMismatchPolicy.Throw` requests runtime validation. Nullable source and nullable result preserve root null. diff --git a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs index 38e5c0c..1ec1b20 100644 --- a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs +++ b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs @@ -2235,7 +2235,13 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod, } var match = MatchSource(targetMember, sourceMembers, options.NameMatching); - if (match.Ambiguous || match.Member == null) + if (match.Ambiguous) + { + diagnostics.Add(Diagnostic.Create(Diagnostics.AmbiguousMemberMatch, targetMember.Locations.FirstOrDefault() ?? rootMethod.Locations.FirstOrDefault(), targetMember.Name)); + return null; + } + + if (match.Member == null) { return null; } @@ -3313,7 +3319,7 @@ private static IEnumerable GetVisibleMembers(ITypeSymbol type, ICollect private static MatchResult MatchSource(ISymbol targetMember, ISymbol[] sourceMembers, string nameMatching) { var exact = sourceMembers.Where(s => s.Name == targetMember.Name).ToArray(); - if (nameMatching == NameMatchingExact || exact.Length == 1) + if (nameMatching == NameMatchingExact || nameMatching != NameMatchingIgnoreCase && exact.Length == 1) { return new MatchResult(exact.Length == 1 ? exact[0] : null, exact.Length > 1); } diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/IgnoreCaseMemberMatchingTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/IgnoreCaseMemberMatchingTests.cs new file mode 100644 index 0000000..c945a74 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Generator.Tests/IgnoreCaseMemberMatchingTests.cs @@ -0,0 +1,214 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Generator.Tests +{ + [TestClass] + public sealed class IgnoreCaseMemberMatchingTests + { + [TestMethod] + [DataRow("create", "properties")] + [DataRow("create", "fields")] + [DataRow("create", "mixed")] + [DataRow("create", "inherited")] + [DataRow("create", "interface")] + [DataRow("update", "properties")] + [DataRow("update", "fields")] + [DataRow("update", "mixed")] + [DataRow("update", "inherited")] + [DataRow("update", "interface")] + [DataRow("nested", "properties")] + [DataRow("nested", "fields")] + [DataRow("nested", "mixed")] + [DataRow("nested", "inherited")] + [DataRow("nested-update", "properties")] + [DataRow("nested-update", "fields")] + [DataRow("nested-update", "mixed")] + [DataRow("nested-update", "inherited")] + public void IgnoreCaseRejectsExactCandidateAmongCaseInsensitiveMatches(string mapping, string members) + { + AssertAmbiguous(Run(Fixture(mapping, members, "IgnoreCase"))); + } + + [TestMethod] + [DataRow("create", "Exact")] + [DataRow("create", "ExactThenIgnoreCase")] + [DataRow("constructor", "Exact")] + [DataRow("constructor", "ExactThenIgnoreCase")] + [DataRow("update", "Exact")] + [DataRow("update", "ExactThenIgnoreCase")] + [DataRow("nested", "Exact")] + [DataRow("nested", "ExactThenIgnoreCase")] + [DataRow("nested-update", "Exact")] + [DataRow("nested-update", "ExactThenIgnoreCase")] + public void ExactPoliciesStillSelectTheExactMember(string mapping, string policy) + { + AssertValidAndRun(Run(Fixture(mapping, "inherited", policy, probe: true))); + } + + [TestMethod] + [DataRow("Exact", "Value")] + [DataRow("ExactThenIgnoreCase", "value")] + [DataRow("IgnoreCase", "value")] + public void UniqueMatchesRemainUsable(string policy, string member) + { + var source = Fixture("create", "properties", policy, probe: true) + .Replace("public int Value { get; set; } = 7; public int value { get; set; } = 99;", "public int " + member + " { get; set; } = 7;"); + AssertValidAndRun(Run(source)); + } + + [TestMethod] + [DataRow("create", "ExactThenIgnoreCase")] + [DataRow("nested", "ExactThenIgnoreCase")] + [DataRow("create", "IgnoreCase")] + public void CaseInsensitiveFallbackStillRejectsMultipleCandidates(string mapping, string policy) + { + AssertAmbiguous(Run(Fixture(mapping, "properties", policy).Replace("public int Value { get; set; } = 7;", "public int VALUE { get; set; } = 7;"))); + } + + [TestMethod] + [DataRow("create")] + [DataRow("update")] + public void ExplicitSourceSelectionDisambiguatesIgnoreCase(string mapping) + { + var source = Fixture(mapping, "properties", "IgnoreCase", probe: true) + .Replace("public static partial " + (mapping == "update" ? "void" : "Target") + " Invalid", "[MapProperty(Source = nameof(Source.Value), Target = nameof(Target.Value))] public static partial " + (mapping == "update" ? "void" : "Target") + " Invalid"); + AssertValidAndRun(Run(source)); + } + + [TestMethod] + public void HiddenMembersAreResolvedBeforeIgnoreCaseMatching() + { + var result = Run(Fixture("create", "inherited", "IgnoreCase", probe: true) + .Replace("public int value { get; set; } = 99;", "public int Value { get; set; } = 99;") + .Replace("public class Source : Base { public int Value", "public class Source : Base { public new int Value")); + CollectionAssert.AreEqual(new[] { "LITEMAPPER1005" }, result.RunResult.Diagnostics.Select(d => d.Id).ToArray()); + AssertValidAndRun(result, allowHiddenWarning: true); + } + + [TestMethod] + [DataRow("assembly")] + [DataRow("method")] + public void InheritedConfigurationStillEnforcesIgnoreCaseAmbiguity(string scope) + { + var source = Fixture("create", "properties", "IgnoreCase"); + source = scope == "assembly" + ? source.Replace("[LiteMapper(NameMatching = NameMatching.IgnoreCase)]", "[assembly: LiteMapperDefaults(NameMatching = NameMatching.IgnoreCase)] [LiteMapper]") + : source.Replace("[LiteMapper(NameMatching = NameMatching.IgnoreCase)]", "[LiteMapper(NameMatching = NameMatching.Exact)]") + .Replace("public static partial Target Invalid", "[MappingOptions(NameMatching = NameMatching.IgnoreCase)] public static partial Target Invalid"); + AssertAmbiguous(Run(source)); + } + + [TestMethod] + public void CandidateDeclarationOrderDoesNotChangeTheDiagnostic() + { + var source = Fixture("nested", "properties", "IgnoreCase"); + var first = Run(source); + var second = Run(source.Replace("public int Value { get; set; } = 7; public int value { get; set; } = 99;", "public int value { get; set; } = 99; public int Value { get; set; } = 7;")); + AssertAmbiguous(first); + AssertAmbiguous(second); + CollectionAssert.AreEqual(first.RunResult.Diagnostics.Select(d => d.ToString()).ToArray(), second.RunResult.Diagnostics.Select(d => d.ToString()).ToArray()); + CollectionAssert.AreEqual(first.RunResult.GeneratedTrees.Select(t => t.ToString()).ToArray(), second.RunResult.GeneratedTrees.Select(t => t.ToString()).ToArray()); + } + + private static string Fixture(string mapping, string members, string policy, bool probe = false) + { + var nested = mapping.StartsWith("nested", StringComparison.Ordinal); + var update = mapping.EndsWith("update", StringComparison.Ordinal); + var sourceType = members == "interface" ? "ISource" : "Source"; + var models = members switch + { + "fields" => "public class Source { public int Value = 7; public int value = 99; }", + "mixed" => "public class Source { public int Value { get; set; } = 7; public int value = 99; }", + "inherited" => "public class Base { public int value { get; set; } = 99; } public class Source : Base { public int Value { get; set; } = 7; }", + "interface" => "public interface IBase { int value { get; } } public interface ISource : IBase { int Value { get; } } public class Source : ISource { public int Value => 7; public int value => 99; }", + _ => "public class Source { public int Value { get; set; } = 7; public int value { get; set; } = 99; }", + }; + var input = nested ? "RootSource" : sourceType; + var output = nested ? "RootTarget" : "Target"; + var declaration = update ? "void Invalid(" + input + " source, " + output + " target);" : output + " Invalid(" + input + " source);"; + return @"using Mammoth.LiteMapper; +[LiteMapper(NameMatching = NameMatching.__POLICY__)] +public static partial class Mapper +{ + public static partial __DECLARATION__ + public static partial HealthyTarget Healthy(HealthySource source); +} +__MODELS__ +public class Target { __TARGET__ } +public class RootSource { public __SOURCE__ Child { get; set; } = new Source(); } +public class RootTarget { public Target Child { get; set; } = new Target(); } +public class HealthySource { public int Other { get; set; } } +public class HealthyTarget { public int Other { get; set; } } +__PROBE__" + .Replace("__POLICY__", policy).Replace("__DECLARATION__", declaration) + .Replace("__MODELS__", models).Replace("__SOURCE__", sourceType) + .Replace("__TARGET__", mapping == "constructor" ? "public Target(int Value) { this.Value = Value; } public int Value { get; }" : "public int Value { get; set; }") + .Replace("new Target();", mapping == "constructor" ? "new Target(0);" : "new Target();") + .Replace("__PROBE__", probe ? "public static class Probe { public static bool Run() { var source = new " + (nested ? "RootSource" : "Source") + "(); " + + (update ? "var target = new " + output + "(); Mapper.Invalid(source, target);" : "var target = Mapper.Invalid(source);") + + " return target." + (nested ? "Child." : "") + "Value == 7; } }" : ""); + } + + private static void AssertAmbiguous(GeneratorRun result) + { + var diagnostics = result.RunResult.Diagnostics.Where(d => d.Id == "LITEMAPPER1004").ToArray(); + Assert.AreEqual(1, diagnostics.Length, string.Join(Environment.NewLine, result.RunResult.Diagnostics)); + var diagnostic = diagnostics[0]; + Assert.AreEqual(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.IsTrue(diagnostic.Descriptor.CustomTags.Contains(WellKnownDiagnosticTags.NotConfigurable)); + StringAssert.Contains(diagnostic.GetMessage(), "Value"); + Assert.AreEqual("Value", diagnostic.Location.SourceTree!.GetText().ToString(diagnostic.Location.SourceSpan)); + Assert.IsFalse(result.RunResult.Diagnostics.Any(d => d.Id == "LITEMAPPER9001")); + var methods = result.RunResult.GeneratedTrees.SelectMany(t => t.GetRoot().DescendantNodes().OfType()).ToArray(); + Assert.IsFalse(methods.Any(m => m.Identifier.ValueText == "Invalid"), "The ambiguous mapping must not receive an implementation."); + Assert.IsTrue(methods.Any(m => m.Identifier.ValueText == "Healthy"), "Independent mappings must still generate."); + var unexpectedErrors = result.Compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error && d.Id != "CS8795").ToArray(); + Assert.AreEqual(0, unexpectedErrors.Length, string.Join(Environment.NewLine, unexpectedErrors.Select(d => d.ToString()))); + } + + private static void AssertValidAndRun(GeneratorRun result, bool allowHiddenWarning = false) + { + Assert.IsFalse(result.RunResult.Diagnostics.Any(d => !allowHiddenWarning || d.Id != "LITEMAPPER1005"), string.Join(Environment.NewLine, result.RunResult.Diagnostics)); + var warningsAndErrors = result.Compilation.GetDiagnostics().Where(d => d.Severity >= DiagnosticSeverity.Warning).ToArray(); + Assert.AreEqual(0, warningsAndErrors.Length, string.Join(Environment.NewLine, warningsAndErrors.Select(d => d.ToString()))); + using var stream = new MemoryStream(); + var emitted = result.Compilation.Emit(stream); + Assert.IsTrue(emitted.Success, string.Join(Environment.NewLine, emitted.Diagnostics)); + Assert.AreEqual(true, Assembly.Load(stream.ToArray()).GetType("Probe")!.GetMethod("Run")!.Invoke(null, null)); + } + + private static GeneratorRun Run(string source) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp9); + var references = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!.ToString()!.Split(Path.PathSeparator) + .Select(static path => MetadataReference.CreateFromFile(path)) + .Concat(new[] { MetadataReference.CreateFromFile(typeof(LiteMapperAttribute).Assembly.Location) }); + var compilation = CSharpCompilation.Create("IgnoreCaseMemberMatchingTests", new[] { CSharpSyntaxTree.ParseText(source, parseOptions) }, references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + var inputErrors = compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error && d.Id != "CS8795").ToArray(); + Assert.AreEqual(0, inputErrors.Length, "Invalid test input: " + string.Join(Environment.NewLine, inputErrors.Select(d => d.ToString()))); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new[] { new LiteMapperGenerator().AsSourceGenerator() }, parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out _); + return new GeneratorRun(driver.GetRunResult(), updatedCompilation); + } + + private sealed class GeneratorRun + { + public GeneratorRun(GeneratorDriverRunResult runResult, Compilation compilation) + { + RunResult = runResult; + Compilation = compilation; + } + + public GeneratorDriverRunResult RunResult { get; } + public Compilation Compilation { get; } + } + } +} diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs index 23db272..5d428a1 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs @@ -78,6 +78,44 @@ public void PackageMappingsCompileAndExecuteAcrossFrameworksAndLanguages(string Assert.IsFalse(Directory.EnumerateFiles(output, "Microsoft.CodeAnalysis*.dll", SearchOption.AllDirectories).Any()); } + [TestMethod] + [DataRow("netstandard2.0")] + [DataRow("net8.0")] + [DataRow("net9.0")] + [DataRow("net10.0")] + public void PackageIgnoreCaseAmbiguityRejectsExactMatches(string framework) + { + var directory = Path.Combine(Path.GetTempPath(), "MammothLiteMapperIgnoreCase", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var properties = Properties(framework, "9.0", Path.Combine(directory, ".packages")); + properties.Add(new XElement("EmitCompilerGeneratedFiles", "true"), + new XElement("CompilerGeneratedFilesOutputPath", "obj/generated")); + new XDocument(new XElement("Project", new XAttribute("Sdk", "Microsoft.NET.Sdk"), properties, + new XElement("ItemGroup", new XElement("PackageReference", + new XAttribute("Include", "Mammoth.LiteMapper"), new XAttribute("Version", "1.0.0"))))) + .Save(Path.Combine(directory, "Consumer.csproj")); + File.WriteAllText(Path.Combine(directory, "Consumer.cs"), @" +using Mammoth.LiteMapper; +[LiteMapper(NameMatching = NameMatching.IgnoreCase)] +public static partial class DirectMapper { public static partial Target Map(Source source); } +[LiteMapper(NameMatching = NameMatching.IgnoreCase)] +public static partial class NestedMapper { public static partial RootTarget Map(RootSource source); } +public class Source { public int Value { get; set; } public int value { get; set; } } +public class Target { public int Value { get; set; } } +public class RootSource { public Source Child { get; set; } = new Source(); } +public class RootTarget { public Target Child { get; set; } = new Target(); }"); + PackageSources(packageFeed, "https://api.nuget.org/v3/index.json") + .Save(Path.Combine(directory, "NuGet.Config")); + Milestone14PackagingAndAotTests.RunDotnet("restore --no-cache", directory); + var result = TestProcess.Run("dotnet", "build -c Release --no-restore", directory, TimeSpan.FromSeconds(60)); + var output = result.Output + result.Error; + Assert.AreNotEqual(0, result.ExitCode, "Ambiguous IgnoreCase mappings must fail compilation. " + output); + StringAssert.Contains(output, "LITEMAPPER1004"); + Assert.IsFalse(output.Contains("LITEMAPPER9001", StringComparison.Ordinal), output); + Assert.AreEqual(0, Directory.GetFiles(Path.Combine(directory, "obj", "generated"), "*.g.cs", SearchOption.AllDirectories).Length, + "Neither invalid mapping may receive a generated implementation."); + } + [TestMethod] [DataRow("Mammoth.LiteMapper")] [DataRow("Mammoth.LiteMapper.Abstractions")]