diff --git a/src/Sharprompt.SourceGenerator/EnumMetadataGenerator.cs b/src/Sharprompt.SourceGenerator/EnumMetadataGenerator.cs index d873676..b18da31 100644 --- a/src/Sharprompt.SourceGenerator/EnumMetadataGenerator.cs +++ b/src/Sharprompt.SourceGenerator/EnumMetadataGenerator.cs @@ -108,7 +108,7 @@ private static EnumMemberMetadata AnalyzeMember(INamedTypeSymbol enumSymbol, IFi int? order = null; var displayAttribute = field.GetAttributes() - .FirstOrDefault(attribute => attribute.AttributeClass?.ToDisplayString() == "System.ComponentModel.DataAnnotations.DisplayAttribute"); + .FirstOrDefault(attribute => IsDisplayAttribute(attribute.AttributeClass)); if (displayAttribute != null) { @@ -128,6 +128,21 @@ private static EnumMemberMetadata AnalyzeMember(INamedTypeSymbol enumSymbol, IFi return new EnumMemberMetadata(field.Name, displayName, order, index); } + private static bool IsDisplayAttribute(ITypeSymbol? type) + { + while (type != null) + { + if (type.ToDisplayString() == "System.ComponentModel.DataAnnotations.DisplayAttribute") + { + return true; + } + + type = type.BaseType; + } + + return false; + } + private static string Quote(string text) { return "\"" + text.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r") + "\""; diff --git a/src/Sharprompt/EnumMetadataRegistry.cs b/src/Sharprompt/EnumMetadataRegistry.cs index 873636d..bc494f6 100644 --- a/src/Sharprompt/EnumMetadataRegistry.cs +++ b/src/Sharprompt/EnumMetadataRegistry.cs @@ -42,17 +42,11 @@ internal static bool TryGet(out IReadOnlyList values, out Func CreateFallbackMetadata() where TEnum : notnull { - // GetFields does not guarantee ordering and MetadataToken is unavailable on - // Native AOT, so sort by the underlying constant value for a deterministic - // order, matching Enum.GetValues semantics. Aliased members sharing the - // same constant value keep a single representative, chosen by ordinal name - // order so the result does not depend on the GetFields ordering either. + // Preserve declaration order for deterministic alias deduplication and secondary + // tie-breaking, matching the semantics of EnumMetadataGenerator. var members = typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static) - .Select(field => (value: (TEnum)field.GetValue(null)!, field.Name, displayAttribute: field.GetCustomAttribute())) - .OrderBy(x => x.value) - .ThenBy(x => x.Name, StringComparer.Ordinal) + .Select((field, index) => (value: (TEnum)field.GetValue(null)!, field.Name, index, displayAttribute: field.GetCustomAttribute())) .DistinctBy(x => x.value) - .Select((x, index) => (x.value, x.Name, index, x.displayAttribute)) .ToArray(); var values = members.OrderBy(x => x.displayAttribute?.GetOrder() ?? int.MaxValue) diff --git a/tests/Sharprompt.Tests/Binding/EnumMetadataRegistryTests.cs b/tests/Sharprompt.Tests/Binding/EnumMetadataRegistryTests.cs index 6bc1b76..1c93c39 100644 --- a/tests/Sharprompt.Tests/Binding/EnumMetadataRegistryTests.cs +++ b/tests/Sharprompt.Tests/Binding/EnumMetadataRegistryTests.cs @@ -74,37 +74,102 @@ public void CreateFallbackMetadata_AliasedValues_KeepsSingleDeterministicReprese { var metadata = EnumMetadataRegistry.CreateFallbackMetadata(); - // Aliases collapse to one entry; the representative is the alias whose - // name sorts first ordinally ("Default" < "None"). + // Aliases collapse to one entry, keeping the first declared representative ("None"). Assert.Equal(new[] { AliasedEnum.None, AliasedEnum.Value }, metadata.Values); - Assert.Equal("Default", metadata.TextSelector(AliasedEnum.None)); - Assert.Equal("Default", metadata.TextSelector(AliasedEnum.Default)); + Assert.Equal("None", metadata.TextSelector(AliasedEnum.None)); + Assert.Equal("None", metadata.TextSelector(AliasedEnum.Default)); + } + + [Fact] + public void Generated_And_Fallback_Metadata_Are_Identical_For_AnnotatedEnum() + { + AssertMetadataParity(); + } + + [Fact] + public void Generated_And_Fallback_Metadata_Are_Identical_For_AliasedEnum() + { + AssertMetadataParity(); + } + + [Fact] + public void Generated_And_Fallback_Metadata_Are_Identical_For_AliasedWithDisplayEnum() + { + AssertMetadataParity(); + + var fallback = EnumMetadataRegistry.CreateFallbackMetadata(); + Assert.Equal(new[] { AliasedWithDisplayEnum.Zero, AliasedWithDisplayEnum.One }, fallback.Values); + Assert.Equal("Zero (First)", fallback.TextSelector(AliasedWithDisplayEnum.Zero)); + Assert.Equal("Zero (First)", fallback.TextSelector(AliasedWithDisplayEnum.Nothing)); + Assert.Equal("One", fallback.TextSelector(AliasedWithDisplayEnum.One)); + } + + [Fact] + public void Generated_And_Fallback_Metadata_Are_Identical_For_ExplicitOrderAndUnannotated() + { + AssertMetadataParity(); + + var fallback = EnumMetadataRegistry.CreateFallbackMetadata(); + Assert.Equal( + new[] + { + AnnotatedOrderEnum.FirstOrder, + AnnotatedOrderEnum.SecondOrder, + AnnotatedOrderEnum.SecondOrderB, + AnnotatedOrderEnum.UnannotatedA, + AnnotatedOrderEnum.UnannotatedB + }, + fallback.Values); + } + + [Fact] + public void Generated_And_Fallback_Metadata_Are_Identical_For_OutOfOrderEnum() + { + AssertMetadataParity(); + + var fallback = EnumMetadataRegistry.CreateFallbackMetadata(); + Assert.Equal(new[] { OutOfOrderEnum.Charlie, OutOfOrderEnum.Alpha, OutOfOrderEnum.Bravo }, fallback.Values); } [Fact] public void SelectOptions_UnregisteredEnum_HasItems() { - var options = new SelectOptions + var options = new SelectOptions { Message = "message" }; options.EnsureOptions(); - Assert.Equal(new[] { FallbackEnum.Foo, FallbackEnum.Bar }, options.Items); + Assert.Equal(Enum.GetValues(), options.Items); } [Fact] public void MultiSelectOptions_UnregisteredEnum_HasItems() { - var options = new MultiSelectOptions + var options = new MultiSelectOptions { Message = "message" }; options.EnsureOptions(); - Assert.Equal(new[] { FallbackEnum.Foo, FallbackEnum.Bar }, options.Items); + Assert.Equal(Enum.GetValues(), options.Items); + } + + private static void AssertMetadataParity() where TEnum : struct, Enum + { + var found = EnumMetadataRegistry.TryGet(out var generatedValues, out var generatedSelector); + Assert.True(found, $"Source generator should have registered metadata for {typeof(TEnum).Name}."); + + var fallback = EnumMetadataRegistry.CreateFallbackMetadata(); + + Assert.Equal(generatedValues, fallback.Values); + + foreach (var value in Enum.GetValues()) + { + Assert.Equal(generatedSelector(value), fallback.TextSelector(value)); + } } public sealed record CustomItem(string Value); @@ -131,9 +196,36 @@ public enum AliasedEnum Value = 1 } - public enum FallbackEnum + public enum AliasedWithDisplayEnum + { + [Display(Name = "Zero (First)")] + Zero = 0, + [Display(Name = "Zero (Second)")] + Nothing = 0, + [Display(Name = "One")] + One = 1 + } + + public enum AnnotatedOrderEnum + { + [Display(Name = "Order 2", Order = 2)] + SecondOrder = 10, + + UnannotatedA = 20, + + [Display(Name = "Order 1", Order = 1)] + FirstOrder = 30, + + UnannotatedB = 40, + + [Display(Name = "Order 2 (Second)", Order = 2)] + SecondOrderB = 50 + } + + public enum OutOfOrderEnum { - Foo, - Bar + Charlie = 3, + Alpha = 1, + Bravo = 2 } } diff --git a/tests/Sharprompt.Tests/Sharprompt.Tests.csproj b/tests/Sharprompt.Tests/Sharprompt.Tests.csproj index 9d1f122..4a863ea 100644 --- a/tests/Sharprompt.Tests/Sharprompt.Tests.csproj +++ b/tests/Sharprompt.Tests/Sharprompt.Tests.csproj @@ -18,6 +18,7 @@ +