-4

I have to write my question in other words.

I'm developing my own geometric primitive structs for some reasons: Point and Size:

public struct Point : IEquatable<Point> {
    public static bool operator ==(Point left, Point right) {
        return left.Equals(right);
    }
    public static bool operator !=(Point left, Point right) {
        return !left.Equals(right);
    }
    public static implicit operator Point(System.Drawing.Point point) {
        return new Point(point.X, point.Y);
    }

    int x;
    int y;

    public int X { get { return x; } set { x = value; } }
    public int Y { get { return y; } set { y = value; } }


    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }


    public bool Equals(Point other) {
        return
            x == other.x &&
            y == other.y;
    }
    public override bool Equals(object obj) {
        if (obj.GetType() != typeof(Point))
            return false;
        return Equals((Point)obj);
    }
    public override int GetHashCode() {
        return x^y;
    }
    public override string ToString() {
        return $"{{X={x.ToString(CultureInfo.CurrentCulture)}, Y={y.ToString(CultureInfo.CurrentCulture)}}}";
    }
    public string ToStringInvariant() {
        return $"{{X={x.ToString(CultureInfo.InvariantCulture)}, Y={y.ToString(CultureInfo.InvariantCulture)}}}";
    }
}

public struct Size : IEquatable<Size> {
    public static bool operator ==(Size size1, Size size2) {
        return size1.Equals(size2);
    }
    public static bool operator !=(Size size1, Size size2) {
        return !size1.Equals(size2);
    }
    public static implicit operator Size(System.Drawing.Size size) {
        return new Size(size.Width, size.Height);
    }

    int width;
    int height;

    public int Width {
        get { return width; }
        set {
            if (value < 0)
                throw new ArgumentException("The Width property value must not be negative.");
            width = value;
        }
    }
    public int Height {
        get { return height; }
        set {
            if (value < 0)
                throw new ArgumentException("The Height property value must not be negative.");
            height = value;
        }
    }

    public Size(int width, int height) {
        if (width < 0 || height < 0)
            throw new ArgumentException("The Width and Height property values must not be negative.");
        this.width = width;
        this.height = height;
    }

    public bool Equals(Size other) {
        return
            width == other.width &&
            height == other.height;
    }
    public override bool Equals(object obj) {
        if (obj.GetType() != typeof(Size))
            return false;
        return Equals((Size)obj);
    }
    public override int GetHashCode() {
        return HashCodeHelper.CalculateGeneric(width, height);
    }
    public override string ToString() {
        return $"{{Width={width.ToString(CultureInfo.CurrentCulture)}, Height={height.ToString(CultureInfo.CurrentCulture)}}}";
    }
    public string ToStringInvariant() {
        return $"{{Width={width.ToString(CultureInfo.InvariantCulture)}, Height={height.ToString(CultureInfo.InvariantCulture)}}}";
    }
}

Now I need to write tests, particularry I should test my ToString and ToStringInvariant() methods.

  1. Test should document current behaviour to avoid unexpected metod behaviour change in the feature.
  2. Assert, that ToString returns string, which contains numbers in Current Culture, but ToStringInvariant always contains string with invariant-formated numbers.

To do this I need some Culture, where integer formatting are differ from InvariantCulture integer number formatting.

Since the Point class can has negative numbers, I can just change the negative sign representation and write the tests:

[Test]
public void ToStringTest() {
   Point point = new Point(-1, 2);
   var preCulture = Thread.CurrentThread.CurrentCulture;
   try {
        var enCulture = new CultureInfo("en-US");
        enCulture.NumberFormat.NegativeSign = "Minus!";
        Thread.CurrentThread.CurrentCulture = enCulture;
        Assert.That(point.ToString() == "{X=Minus!1, Y=2}");
   }
   finally {
        Thread.CurrentThread.CurrentCulture = preCulture;
   }
}

[Test]
public void ToStringInvariantTest() {
    Point point = new Point(1, 2);
    var preCulture = Thread.CurrentThread.CurrentCulture;
    try {
        var enCulture = new CultureInfo("en-US");
        enCulture.NumberFormat.NegativeSign = "Minus!";
        Thread.CurrentThread.CurrentCulture = enCulture;
        Assert.That(point.ToStringInvariant() == "{X=1, Y=2}");
    }
    finally {
        Thread.CurrentThread.CurrentCulture = preCulture;
    }
}

The Size structure can have only positive or zero fields, so I should change the digit representation. I tried to use the "prs-AF" culture. But 1.ToString("prs-AF") is still "1". How to test ToString and ToStringInvariant methods for the Size struct?

OLD TEXT:


I have primitives: Point and Size classes. I override the ToString methods in this way:

public override string ToString() {
    return $"{{X={x.ToString(CultureInfo.CurrentCulture)}, Y={y.ToString(CultureInfo.CurrentCulture)}}}";
}

I also have a

    public string ToStringInvariant() {
        return $"{{X={x.ToString(CultureInfo.InvariantCulture)}, Y={y.ToString(CultureInfo.InvariantCulture)}}}";
    }

I need a test for this method. For the Point class I can set the NegativeSign in a CultureInfo instance:

    [Test]
    public void ToStringTest() {
        Point point = new Point(-1, 2);
        var preCulture = Thread.CurrentThread.CurrentCulture;
        try {
            var enCulture = new CultureInfo("en-US");
            enCulture.NumberFormat.NegativeSign = "Minus!";
            Thread.CurrentThread.CurrentCulture = enCulture;
            Assert.That(point.ToString() == "{X=Minus!1, Y=2}");
        }
        finally {
            Thread.CurrentThread.CurrentCulture = preCulture;
        }
    }

But for the Size class I can't do this, because Width and Height can not be negative, so I need to create a CultureInfo instance, where positive numbers are not consist of {1,2,3,4,5,6,7,8,9,0}.

If there is no such Culture, I just don't need this test for the Size class.

Eugene Maksimov
  • 1,504
  • 17
  • 36
  • Why does it matter? – ProgrammingLlama Jul 14 '22 at 07:43
  • What are you trying to do? Never mind that `CultureInfo.CurrentCulture` is the default in `ToString()`. If you want to test your string formatting code, you don't need a custom CultureInfo. Just use different cultures. – Panagiotis Kanavos Jul 14 '22 at 07:43
  • Are Point and Size primitives? Whats the actual question here? – string.Empty Jul 14 '22 at 07:45
  • If you only have that `ToString()` though, you have a localization problem. You shouldn't have to depend on the ambient culture. That's why all .NET string formatting and parsing methods have overloads that accept a `CultureInfo` parameter. – Panagiotis Kanavos Jul 14 '22 at 07:46
  • My opinion on this question (without clarification as to why it matters): 1) If the characters used for the digits matter enough to be tested (are you testing to ensure they're Arabic numerals?) then it seems like you're using the string for something beyond just providing human-readable information. 2) If you are using the string for data transmission, you should use a consistent culture so that it doesn't get affected by the thread's current culture. I'd suggest `CultureInfo.InvariantCulture` – ProgrammingLlama Jul 14 '22 at 07:49
  • This honestly sounds more like something you'd enforce in a Roslyn analyzer, or else through a sternly-worded comment that the `CurrentCulture` should be used. Attempting to arrange a test for this that verifies that exactly that culture is used is, as you've experienced, unnecessarily convoluted, because you're trying to work backwards from the output. You can't even vary anything in the output that isn't culture-related without having to change the test as well. – Jeroen Mostert Jul 14 '22 at 07:49
  • Panagiotis Kanavos, which culture should I use to test the difference betweent the ToString() and ToStringInvariant() methods. My question is what culture can I use for test? – Eugene Maksimov Jul 14 '22 at 07:50
  • Is what you're doing already not sufficient? – ProgrammingLlama Jul 14 '22 at 07:51
  • It seems that currently, all .NET formatting methods use the "ASCII" values of the digits, though different characters are foreseen: https://learn.microsoft.com/en-us/dotnet/api/system.globalization.numberformatinfo.nativedigits (see especially the Remark at the bottom) – Klaus Gütter Jul 14 '22 at 07:52
  • It is ok for the Point class, but in the Size class, Width and Height are always non-negative. – Eugene Maksimov Jul 14 '22 at 07:52
  • You need a `ToString(CultureInfo)`, not a `ToStringInvariant()`. Almost every `ToString()` method in the BCL works this way. You don't need a custom culture to test your code either. Just pick two different ones. `en-US` and `en-UK` use different decimal separators. – Panagiotis Kanavos Jul 14 '22 at 08:12
  • Finally, it's unclear what you're trying to do. A custom culture would be needed to test eg `int.ToString()`, not your class. Since your class depends entirely on the base type's formatting, the only thing you can really test is the curly braces. – Panagiotis Kanavos Jul 14 '22 at 08:13
  • PS: you can simplify your code a *lot* if you use `String.Format(someCulture,"{X={0}, Y={0}}",X,Y);`. Your `ToString()` could just delegate to `ToString(CultureInfo)`, eg `ToString()=>ToString(CultureInfo.CurrentCulture)`. If you're concerned about negative numbers, make sure the fields can't be negative in the property setters – Panagiotis Kanavos Jul 14 '22 at 08:17
  • 1
    `CultureInfo.GetCultures(CultureTypes.AllCultures).Where(c => c.NumberFormat.NativeDigits.Except(new[]{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}).Any()).Select(c => c.Name)` returns 65 different cultures on my PC – Charlieface Jul 14 '22 at 08:55
  • Thank you guys, I wrote my question in other words, I think the question is clear now. – Eugene Maksimov Jul 14 '22 at 10:10

1 Answers1

0

It's unclear what the question is, or even what is being tested here. ToString only adds the {X=..., Y=...} template, the rest is delegated to the field types (eg decimal or double). There's no point unit-testing decimal.ToString() in a Point.ToString() or Size.ToString() unit test.

Localization-sensitive classes in the Base Class Library accept a CultureInfo parameter anyway, they don't depend on the ambient current culture. In quite a lot of cases, the current culture isn't necessarily the culture we really want, eg in web applications, or when we want read or write files in a specific locale.

It would be better to have a ToString(CultureInfo) method that others delegate to. This would simplify testing and reduce duplication. Using String.Format will reduce the code even further and reduce the chance of errors:

public string ToString(CultureInfo culture)
{
    return String.Format(culture,"{X={0}, Y={1}}",X,Y);
}

public override string ToString()=>ToString(CultureInfo.CurrentCulture);

//Really, this should be ToStringUS();
public string ToStringInvariant()=>ToString(CultureInfo.InvariantCulture);

The format string can have different sections to specify different formatting for negative numbers or zero, eg :

"{X={0:#;(#)}, Y={1:#;(#)}}"

or

"{X={0:#;Minus!#;**Zero**}, Y={1:#;Minus!#;**Zero**}}"

Rant: InvariantCulture is essentially the US culture. It's OK for numbers but becomes a serious problem when formatting dates. Instead of the standard YYYY-MM-DD format, it produces US-style dates.

No matter how the methods are implemented, the only part that needs unit testing is that method's output, not how decimal itself gets formatted.

[TestCase("en-US","{X=-1.5, Y=2.6}")
[TestCase("en-UK","{X=-1,5, Y=2,6}")
public void ToString_Is_Locale_Aware(string culture,string expected)
{
    var ci=CultureInfo.GetCulture(culture);
    var point=new Point(-1.5,2.6);
    
    var actual=point.ToString(ci);

    Assert.AreEqual(expected,actual);
}

[TestCase("en-US","{X=-1.5, Y=2.6}")
[TestCase("en-UK","{X=-1,5, Y=2,6}")
public void ToStringBase_Is_Locale_Aware(string culture,string expected)
{
    var point=new Point(-1.5,2.6);
    var ci=CultureInfo.GetCulture(culture);    
    var preCulture = Thread.CurrentThread.CurrentCulture;
    try {
        Thread.CurrentThread.CurrentCulture = ci;

        var actual=point.ToString();

        Assert.AreEqual(expected,actual);
    }
    finally {
        Thread.CurrentThread.CurrentCulture = preCulture;
    }
}

Testing ToStringInvariant only requires proving the string doesn't change

[TestCase("en-US")
[TestCase("en-UK")
[TestCase("de-DE")
public void ToStringInvariant_Is_Locale_Unaware(string culture)
{
    var point=new Point(-1.5,2.6);    
    var ci=CultureInfo.GetCulture(culture);
    var preCulture = Thread.CurrentThread.CurrentCulture;
    try {
        Thread.CurrentThread.CurrentCulture = ci;

        var actual=point.ToStringInvariant();

        Assert.AreEqual("{X=-1.5, Y=2.6}",actual);
    }
    finally {
        Thread.CurrentThread.CurrentCulture = preCulture;
    }
}
Panagiotis Kanavos
  • 120,703
  • 13
  • 188
  • 236