Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/Verify.Tests/DateFormatLengthCalculatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
14 changes: 10 additions & 4 deletions src/Verify/Serialization/Scrubbers/DateFormatLengthCalculator.cs
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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);

Expand Down
13 changes: 10 additions & 3 deletions src/Verify/Serialization/Scrubbers/DateMatchers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Culture, Scrubber> 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<DateTimeFormatInfo, Scrubber> 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
Expand Down
Loading