fluentassertions icon indicating copy to clipboard operation
fluentassertions copied to clipboard

`ArgumentOutOfRangeException` when formatting objects using `UseLineBreaks`

Open jnyrup opened this issue 1 year ago • 15 comments

Description

When using ReferenceTypeAssertions.BeSameAs and failing I got a ArgumentOutOfRangeException instead of an XunitException.

From the stack trace the problem lies somewhere in the formatting of the subject combined with using UseLineBreaks.

Only a few assertions specify UseLineBreaks per default, which is why we might not have seen this before.

Reproduction Steps

[Fact]
public void Array_of_arrays_of_toStringable_objects()
{
    var sigmets = new Point[][]
    {
        [new Point()],
        [new Point()],
    };

    Formatter.ToString(sigmets, new() { UseLineBreaks = true });
}

[Fact]
public void Array_of_objects_with_array_of_toStringable_objects()
{
    var sigmets = new[]
    {
        new
        {
            Points = new Point[] { new() }
        },
        new
        {
            Points = new Point[] { new() }
        }
    };

    Formatter.ToString(sigmets, new() { UseLineBreaks = true });
}

private class Point
{
    public override string ToString() => "P";
}

Expected behavior

Both tests should be able to complete without throwing an exception

Actual behavior

Array_of_arrays_of_toStringable_objects:

Message: 
Test method TestProject30.UnitTest1.Array_of_arrays_of_toStringable_objects threw exception: 
System.ArgumentOutOfRangeException: startIndex ('3') must be less than or equal to '0'. (Parameter 'startIndex')
Actual value was 3.

  Stack Trace: 
ArgumentOutOfRangeException.ThrowGreater[T](T value, T other, String paramName)
String.Insert(Int32 startIndex, String value)
PossibleMultilineFragment.InsertLineOrFragment(String fragment)
EnumerableValueFormatter.Format(Object value, FormattedObjectGraph formattedGraph, FormattingContext context, FormatChild formatChild)
Formatter.Format(Object value, FormattedObjectGraph output, FormattingContext context, FormatChild formatChild)
Formatter.ToString(Object value, FormattingOptions options)
UnitTest1.Array_of_arrays_of_toStringable_objects() line 17
RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)

Array_of_objects_with_array_of_toStringable_objects:

Message: 
Test method TestProject30.UnitTest1.Array_of_objects_with_array_of_toStringable_objects threw exception: 
System.ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')

  Stack Trace: 
List`1.get_Item(Int32 index)
PossibleMultilineFragment.InsertAtStartOfLine(Int32 lineIndex, String insertion)
PossibleMultilineFragment.AddStartingLineOrFragment(String fragment)
EnumerableValueFormatter.Format(Object value, FormattedObjectGraph formattedGraph, FormattingContext context, FormatChild formatChild)
Formatter.Format(Object value, FormattedObjectGraph output, FormattingContext context, FormatChild formatChild)
Formatter.FormatChild(String path, Object value, FormattedObjectGraph output, FormattingContext context, FormattingOptions options, ObjectGraph graph)
<>c__DisplayClass6_0.<FormatChild>b__0(String childPath, Object childValue, FormattedObjectGraph nestedOutput)
DefaultValueFormatter.WriteMemberValueTextFor(Object value, MemberInfo member, FormattedObjectGraph formattedGraph, FormatChild formatChild)
DefaultValueFormatter.WriteMemberValues(Object obj, MemberInfo[] members, FormattedObjectGraph formattedGraph, FormatChild formatChild)
DefaultValueFormatter.WriteTypeValue(Object obj, FormattedObjectGraph formattedGraph, FormatChild formatChild, Type type)
DefaultValueFormatter.WriteTypeAndMemberValues(Object obj, FormattedObjectGraph formattedGraph, FormatChild formatChild)
DefaultValueFormatter.Format(Object value, FormattedObjectGraph formattedGraph, FormattingContext context, FormatChild formatChild)
Formatter.Format(Object value, FormattedObjectGraph output, FormattingContext context, FormatChild formatChild)
Formatter.FormatChild(String path, Object value, FormattedObjectGraph output, FormattingContext context, FormattingOptions options, ObjectGraph graph)
<>c__DisplayClass5_0.<ToString>b__0(String path, Object childValue, FormattedObjectGraph output)
EnumerableValueFormatter.Format(Object value, FormattedObjectGraph formattedGraph, FormattingContext context, FormatChild formatChild)
Formatter.Format(Object value, FormattedObjectGraph output, FormattingContext context, FormatChild formatChild)
Formatter.ToString(Object value, FormattingOptions options)
UnitTest1.Array_of_objects_with_array_of_toStringable_objects() line 35
RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)

Regression?

Yes

In 6.11.0 Formatter.ToString doesn't throw.

Known Workarounds

No response

Configuration

Tried with both Version 6.12 and develop

Other information

By looking in the release notes for 6.12 I suspect #2144 CC: @benagain

Are you willing to help with a pull-request?

Yes, if anyone can figure out what the problem is

jnyrup avatar Jul 15 '24 11:07 jnyrup

The second one is actually a pretty easy fix.

But the first one is pretty tricky.. because if you handle the ArgumentOutOfRangeException the output is still messed up

See:


{
    {
    {P}
            P
    }
}

ITaluone avatar Jul 16 '24 08:07 ITaluone

What where your findings? So far I've been focusing on FormattedObjectGraph.AddFragmentOnNewLine.

Preferably we should not handle ArgumentOutOfRangeException but avoid it from ever happening

jnyrup avatar Jul 16 '24 08:07 jnyrup

[Fact]
public void Array_of_objects_with_array_of_toStringable_objects()
{
    var sigmets = new[]
    {
        new
        {
            Points = new Point[] { new() }
        },
        new
        {
            Points = new Point[] { new() }
        }
    };

    var result = Formatter.ToString(sigmets, new() { UseLineBreaks = true });

    result.Should().Be(
        """

        {
            {
                Points =
                {
                    P
                }
            },
            {
                Points =
                {
                    P
                }
            }
        }
        """);
}
    private void InsertAtStartOfLine(int lineIndex, string insertion)
    {
+      lineIndex = parentGraph.lines.Count <= lineIndex ? parentGraph.lines.Count - 1 : lineIndex;

        if (!parentGraph.lines[lineIndex].StartsWith(insertion, StringComparison.Ordinal))
        {
            parentGraph.lines[lineIndex] = parentGraph.lines[lineIndex].Insert(0, insertion);
        }
     }

All tests green (except the first one in this ticket)

Preferably we should not handle ArgumentOutOfRangeException but avoid it from ever happening

That's what I meant 😉

ITaluone avatar Jul 16 '24 09:07 ITaluone

In Array_of_arrays_of_toStringable_objects it is very confused - It doesn't indent the inner arrays; puts the comma between the two arrays on the wrong line, and doesn't wrap the second array in braces:


{
    , 
    {
    {P}
            P
    }
}

benagain avatar Jul 16 '24 10:07 benagain

It's something to do with the first sub-array...

var sigmets = new Point[][]
{
    [new Point(), new Point(), new Point()],
    [new Point(), new Point()],
    [new Point(), new Point()],
};

results in


{
    {
    P, 
        P, 
            P
    }, 
    {
        P, 
        P
    }, 
    {
        P, 
        P
    }
}

benagain avatar Jul 17 '24 08:07 benagain

The ArgumentOutOfRangeException does only occur, if the first inner array has only one element. This is producing the malformed formatting, but no exception

var sigmets = new Point[][]
{
    [new Point(), new Point()],
    [new Point()],
    [new Point()],
};

ITaluone avatar Jul 17 '24 09:07 ITaluone

Interesting findings from both of you. Thanks for helping looking into this.

jnyrup avatar Jul 20 '24 06:07 jnyrup

As it seems, the formatting does not recognize, that the first item in the first sub-array is one intendation layer deeper

var sigmets = new Point[][]
{
    [new Point(), new Point(), new Point()],
    [new Point(), new Point()],
    [new Point(), new Point()],
};

{
    {
    P,  <--- here the intendation is 1 as well
        P, 
            P
    }, 
    {
        P, 
        P
    }, 
    {
        P, 
        P
    }
}

IT-VBFK avatar Jul 20 '24 07:07 IT-VBFK

I suggest we remove the usage of UsingLineBreaks from BeSameAs and NotBeSameAs. They are the only two assertions in ReferenceTypeAssertions that use UsingLineBreaks.

While it doesn't solve the problem if an end user chooses to use UsingLineBreaks, it should solve the case for the default usage. @dennisdoomen What do you think?

jnyrup avatar Aug 10 '24 20:08 jnyrup

Yeah, makes sense. We should also investigate what we intend to accomplish with UsingLineBreaks.

dennisdoomen avatar Aug 11 '24 07:08 dennisdoomen

I think the problem boils down to this:

[Fact]
public void Indentation_before_first_fragment()
{
    var formatter = new FormattedObjectGraph(100);
    formatter.WithIndentation();          // from EnumerableValueFormatter (indirectly)
    formatter.AddFragmentOnNewLine("P");  // from DefaultValueFormatter
    formatter.AddFragmentOnNewLine("P");  // from DefaultValueFormatter

    var s = formatter.ToString();
    s.Should().Be(
        """
            P
            P
        """);
}

The formatted string is actually

P
    P

and this is because FormattedObjectGraph.lineBuilderWhitespace is not set until after the first fragment is added.

I traced through what's going on when we format those arrays, and these are the calls made on FormattedObjectGraph:

EnumerableValueFormatter calls formatChild on the first element https://github.com/fluentassertions/fluentassertions/blob/08178fc2a24ac11fe99f4037ab758db4be3e5cc8/Src/FluentAssertions/Formatting/EnumerableValueFormatter.cs#L43

which calls WithIndentation on the formatter https://github.com/fluentassertions/fluentassertions/blob/08178fc2a24ac11fe99f4037ab758db4be3e5cc8/Src/FluentAssertions/Formatting/Formatter.cs#L146-L150

which uses DefaultValueFormatter that calls AddFragmentToline on the formatter https://github.com/fluentassertions/fluentassertions/blob/08178fc2a24ac11fe99f4037ab758db4be3e5cc8/Src/FluentAssertions/Formatting/DefaultValueFormatter.cs#L35-L38

The problem is that FormattedObjectGraph knows nothing about UseLineBreaks and so shouldn't automatically indent the first line, it's the interplay between EnumerableFormatter and DefaultValueFormatter. It's tricky to work out where to add the whitespace that won't break all the other use cases.

benagain avatar Aug 13 '24 03:08 benagain

First of all thank you for the awesome library! I assume this bug it's still present, right? I think it's a really serious bug as it prevents the vast majority of assertions I make since I use BeEquivalentTo with collections a lot...

fteotini-remira avatar Nov 04 '24 10:11 fteotini-remira

@fteotini-remira This bug is still present but to our knowledge, as hopefully presented in this issue, it "only" happens under very specific circumstances. From v6.12.0 (which introduced/exposed the bug) was released on 2023-08-23 it took almost a year until I stumbled across this bug and reported it. So given the number of people who seem affected by this, I don't think this a really serious bug.

jnyrup avatar Nov 04 '24 16:11 jnyrup

@jnyrup It happened to me very often when using BeEquivalentTo related assertions on EF entities with more than a few of nested relationships. (Yeah I know this is a really hideous case, but this is the client code I'm working on)

fteotini-remira avatar Nov 06 '24 14:11 fteotini-remira

@fteotini-remira do you have an example to share?

jnyrup avatar Nov 06 '24 15:11 jnyrup

Resolved in #3008

dennisdoomen avatar Mar 26 '25 22:03 dennisdoomen