diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs index d6e1d6cbd3a301..51874b7abaec0b 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs @@ -454,8 +454,17 @@ private bool IsDefinedValueOrCombinationOfValues(ulong key) { if (s_isFlagsEnum) { - ulong remainingBits = key; + // First try to match exact field (for performance) + foreach (EnumFieldInfo fieldInfo in _enumFieldInfo) + { + if (fieldInfo.Key == key) + { + return true; + } + } + // Try the standard bit-by-bit matching + ulong remainingBits = key; foreach (EnumFieldInfo fieldInfo in _enumFieldInfo) { ulong fieldKey = fieldInfo.Key; @@ -470,6 +479,27 @@ private bool IsDefinedValueOrCombinationOfValues(ulong key) } } + // If the above failed, try matching by analyzing field combinations + // This ensures backwards compatibility with .NET 8 behavior for flags enums with combination values + if (remainingBits != 0) + { + // Similar to the approach used by FormatEnumAsString, but we don't need to build the actual string. + // We only need to know if any field matches at all - if it does, we'll format it as a string. + // This mimics the .NET 8 behavior where any flags enum with at least one named field would + // be formatted as a string rather than a number. + ulong unmatchedBits = key; + + foreach (EnumFieldInfo enumField in _enumFieldInfo) + { + ulong fieldKey = enumField.Key; + if (fieldKey == 0 ? key == 0 : (unmatchedBits & fieldKey) == fieldKey) + { + unmatchedBits &= ~fieldKey; + return true; // Found at least one field that matches part of the value + } + } + } + return false; } else diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs index aecc33a26760b1..0c8292f67154d5 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs @@ -1007,6 +1007,218 @@ public enum EnumFlagsWithMemberAttributes Value4 = 8, Value5 = 16, } + + /// + /// Flag enum used to test serialization of flags where all bits have defined values. + /// + [Flags] + public enum MyFlagsEnum1 + { + UNKNOWN = 0, + BIT0 = 1, + BIT1 = 2, + BIT2 = 4, + BIT3 = 8, + BITS01 = 3, + } + + /// + /// Flag enum used to test serialization of flags where not all bits have defined values. + /// This is the regression case for the issue fixed in this PR. + /// + [Flags] + public enum MyFlagsEnum2 + { + UNKNOWN = 0, + BIT0 = 1, + // direct option for bit 1 missing + BIT2 = 4, + BIT3 = 8, + BITS01 = 3, + } + + /// + /// Complex flag enum with various combinations and missing definitions for certain combinations. + /// + [Flags] + public enum ComplexFlagsEnum + { + None = 0, + Flag1 = 1, + Flag2 = 2, + Flag4 = 4, + Flag8 = 8, + Flag16 = 16, + Flag32 = 32, + Combo1And2 = Flag1 | Flag2, + Combo4And8And16 = Flag4 | Flag8 | Flag16, + // No definition for Flag32 combinations + } + + /// + /// Flag enum with negative values to test serialization behavior + /// + [Flags] + public enum FlagsWithNegativeValues + { + None = 0, + Negative = -2147483648, // int.MinValue + Max = 0x7FFFFFFF, // int.MaxValue + All = -1 // All bits set to 1 (0xFFFFFFFF) + } + + /// + /// Flag enum with overlapping values to test serialization behavior + /// + [Flags] + public enum FlagsWithOverlappingValues + { + None = 0, + One = 1, + Two = 2, + Four = 4, + All = 7, + OneAndFour = 5, + // Both All and (One, Two, Four) represent value 7 + } + + /// + /// Test for the flags enum regression fix where flag enums with missing bit definitions + /// would be serialized as numbers instead of strings in .NET 9 but not in .NET 8. + /// + [Fact] + public static void JsonStringEnumConverter_SerializesFlagsEnumWithMissingBits() + { + JsonSerializerOptions options = new() + { + WriteIndented = false, + Converters = { new JsonStringEnumConverter() } + }; + + // Test that our standard enum with all bits defined serializes correctly + var e1 = MyFlagsEnum1.BITS01 | MyFlagsEnum1.BIT3; + string json1 = JsonSerializer.Serialize(e1, options); + Assert.Equal("\"BITS01, BIT3\"", json1); + + // Verify the enum with missing bit definition produces the same result + // This is the key regression test + var e2 = MyFlagsEnum2.BITS01 | MyFlagsEnum2.BIT3; + string json2 = JsonSerializer.Serialize(e2, options); + Assert.Equal("\"BITS01, BIT3\"", json2); + } + + /// + /// Test for complex flags enum serialization with the JsonStringEnumConverter. + /// Ensures combinations that include bits without direct field definitions are + /// still serialized as strings rather than falling back to numeric representation. + /// + [Fact] + public static void JsonStringEnumConverter_SerializesComplexFlagsCombinations() + { + JsonSerializerOptions options = new() + { + WriteIndented = false, + Converters = { new JsonStringEnumConverter() } + }; + + // Test a complex combination including a flag that doesn't have + // a direct field definition but can be represented by other fields + var value = ComplexFlagsEnum.Combo1And2 | ComplexFlagsEnum.Combo4And8And16 | ComplexFlagsEnum.Flag32; + string json = JsonSerializer.Serialize(value, options); + + // Verify we get the exact expected JSON string + Assert.Equal("\"Combo1And2, Combo4And8And16, Flag32\"", json); + } + + /// + /// Test flag enum serialization with JsonStringEnumConverter + /// for values that include flags with overlapping or missing definitions. + /// + [Fact] + public static void JsonStringEnumConverter_SerializesFlagEnumsWithOverlappingAndMissingDefinitions() + { + JsonSerializerOptions options = new() + { + WriteIndented = false, + Converters = { new JsonStringEnumConverter() } + }; + + // Test AttributeTargets which is a [Flags] enum in the BCL + Assert.Equal("\"Class, Delegate\"", JsonSerializer.Serialize( + AttributeTargets.Class | AttributeTargets.Delegate, + options)); + + // Test with more flags + Assert.Equal("\"Class, Delegate, Interface\"", JsonSerializer.Serialize( + AttributeTargets.Class | AttributeTargets.Delegate | AttributeTargets.Interface, + options)); + + // Test All value + Assert.Equal("\"All\"", JsonSerializer.Serialize(AttributeTargets.All, options)); + + // Test with overlapping values + Assert.Equal("\"All\"", JsonSerializer.Serialize(FlagsWithOverlappingValues.All, options)); + Assert.Equal("\"One, Four\"", JsonSerializer.Serialize(FlagsWithOverlappingValues.OneAndFour, options)); + + // Test One + Two + Four (equals All) + Assert.Equal("\"All\"", JsonSerializer.Serialize( + FlagsWithOverlappingValues.One | FlagsWithOverlappingValues.Two | FlagsWithOverlappingValues.Four, + options)); + } + + /// + /// Test flag enum serialization for negative values and boundary conditions + /// + [Fact] + public static void JsonStringEnumConverter_SerializesFlagEnumsWithNegativeValues() + { + JsonSerializerOptions options = new() + { + WriteIndented = false, + Converters = { new JsonStringEnumConverter() } + }; + + // Test individual flags + Assert.Equal("\"Negative\"", JsonSerializer.Serialize(FlagsWithNegativeValues.Negative, options)); + Assert.Equal("\"Max\"", JsonSerializer.Serialize(FlagsWithNegativeValues.Max, options)); + Assert.Equal("\"All\"", JsonSerializer.Serialize(FlagsWithNegativeValues.All, options)); + + // Test combinations + Assert.Equal("\"All\"", JsonSerializer.Serialize( + FlagsWithNegativeValues.Negative | FlagsWithNegativeValues.Max, + options)); + } + + /// + /// Test for round-trip serialization and deserialization of flag enums + /// + [Fact] + public static void JsonStringEnumConverter_RoundTripsFlagEnums() + { + JsonSerializerOptions options = new() + { + WriteIndented = false, + Converters = { new JsonStringEnumConverter() } + }; + + // Round trip with enum that has all bits defined + var e1 = MyFlagsEnum1.BIT0 | MyFlagsEnum1.BIT2; + string json1 = JsonSerializer.Serialize(e1, options); + var e1Result = JsonSerializer.Deserialize(json1, options); + Assert.Equal(e1, e1Result); + + // Round trip with enum that has missing bit definitions + var e2 = MyFlagsEnum2.BIT0 | MyFlagsEnum2.BIT2; + string json2 = JsonSerializer.Serialize(e2, options); + var e2Result = JsonSerializer.Deserialize(json2, options); + Assert.Equal(e2, e2Result); + + // Round trip with complex combinations + var complex = ComplexFlagsEnum.Combo1And2 | ComplexFlagsEnum.Flag32; + string jsonComplex = JsonSerializer.Serialize(complex, options); + var complexResult = JsonSerializer.Deserialize(jsonComplex, options); + Assert.Equal(complex, complexResult); + } [Theory] [InlineData(EnumWithConflictingMemberAttributes.Value1)] @@ -1257,5 +1469,89 @@ public enum EnumWithVaryingNamingPolicies A, b, } + + /// + /// Test for the flags enum regression fix where flag enums with missing bit definitions + /// would be serialized as numbers instead of strings in .NET 9 but not in .NET 8. + /// + [Fact] + public static void JsonStringEnumConverter_SerializesFlagsEnumWithMissingBits() + { + JsonSerializerOptions options = new() + { + WriteIndented = false, + Converters = { new JsonStringEnumConverter() } + }; + + // Test that our standard enum with all bits defined serializes correctly + var e1 = MyFlagsEnum1.BITS01 | MyFlagsEnum1.BIT3; + string json1 = JsonSerializer.Serialize(e1, options); + Assert.Equal("\"BITS01, BIT3\"", json1); + + // Verify the enum with missing bit definition produces the same result + // This is the key regression test + var e2 = MyFlagsEnum2.BITS01 | MyFlagsEnum2.BIT3; + string json2 = JsonSerializer.Serialize(e2, options); + Assert.Equal("\"BITS01, BIT3\"", json2); + } + + /// + /// Test for complex flags enum serialization with the JsonStringEnumConverter. + /// Ensures combinations that include bits without direct field definitions are + /// still serialized as strings rather than falling back to numeric representation. + /// + [Fact] + public static void JsonStringEnumConverter_SerializesComplexFlagsCombinations() + { + JsonSerializerOptions options = new() + { + WriteIndented = false, + Converters = { new JsonStringEnumConverter() } + }; + + // Test a complex combination including a flag that doesn't have + // a direct field definition but can be represented by other fields + var value = ComplexFlagsEnum.Combo1And2 | ComplexFlagsEnum.Combo4And8And16 | ComplexFlagsEnum.Flag32; + string json = JsonSerializer.Serialize(value, options); + + // Verify we get the exact expected JSON string + Assert.Equal("\"Combo1And2, Combo4And8And16, Flag32\"", json); + } + + /// + /// Test flag enum serialization with JsonStringEnumConverter + /// for values that include flags with overlapping or missing definitions. + /// + [Fact] + public static void JsonStringEnumConverter_SerializesFlagEnumsWithOverlappingAndMissingDefinitions() + { + JsonSerializerOptions options = new() + { + WriteIndented = false, + Converters = { new JsonStringEnumConverter() } + }; + + // Test AttributeTargets which is a [Flags] enum in the BCL + Assert.Equal("\"Class, Delegate\"", JsonSerializer.Serialize( + AttributeTargets.Class | AttributeTargets.Delegate, + options)); + + // Test with more flags + Assert.Equal("\"Class, Delegate, Interface\"", JsonSerializer.Serialize( + AttributeTargets.Class | AttributeTargets.Delegate | AttributeTargets.Interface, + options)); + + // Test All value + Assert.Equal("\"All\"", JsonSerializer.Serialize(AttributeTargets.All, options)); + + // Test with overlapping values + Assert.Equal("\"All\"", JsonSerializer.Serialize(FlagsWithOverlappingValues.All, options)); + Assert.Equal("\"One, Four\"", JsonSerializer.Serialize(FlagsWithOverlappingValues.OneAndFour, options)); + + // Test One + Two + Four (equals All) + Assert.Equal("\"All\"", JsonSerializer.Serialize( + FlagsWithOverlappingValues.One | FlagsWithOverlappingValues.Two | FlagsWithOverlappingValues.Four, + options)); + } } }