diff --git a/src/Verify.Tests/DateFormatLengthCalculatorTests.cs b/src/Verify.Tests/DateFormatLengthCalculatorTests.cs index a1e0d9782..e4fd846d4 100644 --- a/src/Verify.Tests/DateFormatLengthCalculatorTests.cs +++ b/src/Verify.Tests/DateFormatLengthCalculatorTests.cs @@ -106,6 +106,33 @@ public void Combos(string format, int max, int min) Assert.Equal(min + 2, length.min); } + // Two cultures can share a name and still render dates differently, so the cache + // cannot be keyed on the name: whichever was measured first would supply the bounds + // for the other, and rendered dates would fall outside the probed window lengths. + [Fact] + public void SameNamedCulturesWithDifferentFormatsAreNotShared() + { + var standard = new CultureInfo("en-AU"); + var customized = new CultureInfo("en-AU") + { + DateTimeFormat = + { + // A far longer designator, so the bounds cannot coincide by accident + PMDesignator = "in the afternoon", + AMDesignator = "in the morning" + } + }; + + var standardLength = DateFormatLengthCalculator.GetLength("h:mm tt", standard); + var customizedLength = DateFormatLengthCalculator.GetLength("h:mm tt", customized); + + Assert.NotEqual(standardLength, customizedLength); + + var rendered = new DateTime(2020, 1, 1, 13, 30, 0).ToString("h:mm tt", customized); + Assert.True(rendered.Length <= customizedLength.max, $"{rendered.Length} <= {customizedLength.max}. {rendered}"); + Assert.True(rendered.Length >= customizedLength.min, $"{rendered.Length} >= {customizedLength.min}. {rendered}"); + } + // MMMM next to a day component renders the genitive month name, which can be longer // than every nominative form (cs-CZ November: "listopadu" vs "listopad") [Fact] diff --git a/src/Verify/Serialization/Scrubbers/DateFormatLengthCalculator.cs b/src/Verify/Serialization/Scrubbers/DateFormatLengthCalculator.cs index a60fa184b..75110b13b 100644 --- a/src/Verify/Serialization/Scrubbers/DateFormatLengthCalculator.cs +++ b/src/Verify/Serialization/Scrubbers/DateFormatLengthCalculator.cs @@ -1,6 +1,12 @@ static class DateFormatLengthCalculator { - static ConcurrentDictionary<(string cultureName, string format), (int max, int min)> cache = new(); + // Keyed on DateTimeFormat rather than the culture name. Two cultures can share a name + // and still render dates differently: CurrentCulture carrying Windows user overrides + // against CultureInfo.GetCultureInfo of the same name, or a clone whose DateTimeFormat + // was replaced. Keying on the name let whichever was measured first supply the window + // bounds for the other, so rendered dates fell outside the probed lengths and silently + // stopped scrubbing. + static ConcurrentDictionary<(DateTimeFormatInfo dateTimeFormat, string format), (int max, int min)> cache = new(); const int maxSecondsFractionDigits = 7; static void ValidateSecondsFractionLength(int tokenLen) @@ -13,11 +19,11 @@ static void ValidateSecondsFractionLength(int tokenLen) public static (int max, int min) GetLength(string format, Culture culture) => cache.GetOrAdd( - (culture.Name, format), + (culture.DateTimeFormat, format), static (key, culture) => { - var format = culture.DateTimeFormat.ExpandFormat(key.format); - return InnerGetLength(format.AsSpan(), culture); + var expanded = culture.DateTimeFormat.ExpandFormat(key.format); + return InnerGetLength(expanded.AsSpan(), culture); }, culture); diff --git a/src/Verify/Serialization/Scrubbers/DateMatchers.cs b/src/Verify/Serialization/Scrubbers/DateMatchers.cs index bb9bd22f0..a36fe4c81 100644 --- a/src/Verify/Serialization/Scrubbers/DateMatchers.cs +++ b/src/Verify/Serialization/Scrubbers/DateMatchers.cs @@ -141,17 +141,24 @@ static Scrubber ForCulture( // No culture was supplied, so each scrub uses the culture in effect at that // point. Building a scrubber reads the format lengths and expands the // pattern, so the result is cached per culture. - ConcurrentDictionary cache = new(); + // + // Keyed on DateTimeFormat rather than the culture, because CultureInfo equality + // only compares the name and the compare/text info. A CurrentCulture carrying + // Windows user overrides is equal to CultureInfo.GetCultureInfo of the same name + // while rendering dates differently, so keying on the culture let whichever was + // seen first answer for both. + ConcurrentDictionary cache = new(); Scrubber ForCurrentCulture() { var current = Culture.CurrentCulture; - if (cache.TryGetValue(current, out var existing)) + var key = current.DateTimeFormat; + if (cache.TryGetValue(key, out var existing)) { return existing; } - return cache.GetOrAdd(current, Single(format, current, parseFactory(format, current))); + return cache.GetOrAdd(key, Single(format, current, parseFactory(format, current))); } // The registration culture instance supplies the bounds used for ordering