Refactor RangeHelper (#200)

Addresses #196
This commit is contained in:
Jass Bagga 2017-06-06 11:22:31 -07:00 committed by GitHub
parent c5836c2b71
commit ad8338e1e8
4 changed files with 88 additions and 264 deletions

View File

@ -2,8 +2,8 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Headers; using Microsoft.AspNetCore.Http.Headers;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
@ -17,19 +17,25 @@ namespace Microsoft.AspNetCore.Internal
internal static class RangeHelper internal static class RangeHelper
{ {
/// <summary> /// <summary>
/// Returns the requested range if the Range Header in the <see cref="HttpContext.Request"/> is valid. /// Returns the normalized form of the requested range if the Range Header in the <see cref="HttpContext.Request"/> is valid.
/// </summary> /// </summary>
/// <param name="context">The <see cref="HttpContext"/> associated with the request.</param> /// <param name="context">The <see cref="HttpContext"/> associated with the request.</param>
/// <param name="requestHeaders">The <see cref="RequestHeaders"/> associated with the given <paramref name="context"/>.</param> /// <param name="requestHeaders">The <see cref="RequestHeaders"/> associated with the given <paramref name="context"/>.</param>
/// <param name="lastModified">The <see cref="DateTimeOffset"/> representation of the last modified date of the file.</param> /// <param name="length">The total length of the file representation requested.</param>
/// <param name="etag">The <see cref="EntityTagHeaderValue"/> provided in the <see cref="HttpContext.Request"/>.</param> /// <returns>A boolean value which represents if the <paramref name="requestHeaders"/> contain a single valid
/// <returns>A collection of <see cref="RangeItemHeaderValue"/> containing the ranges parsed from the <paramref name="requestHeaders"/>.</returns> /// range request. A <see cref="RangeItemHeaderValue"/> which represents the normalized form of the
public static ICollection<RangeItemHeaderValue> ParseRange(HttpContext context, RequestHeaders requestHeaders, DateTimeOffset? lastModified = null, EntityTagHeaderValue etag = null) /// range parsed from the <paramref name="requestHeaders"/> or <c>null</c> if it cannot be normalized.</returns>
/// <remark>If the Range header exists but cannot be parsed correctly, or if the provided length is 0, then the range request cannot be satisfied (status 416).
/// This results in (<c>true</c>,<c>null</c>) return values.</remark>
public static (bool, RangeItemHeaderValue) ParseRange(
HttpContext context,
RequestHeaders requestHeaders,
long length)
{ {
var rawRangeHeader = context.Request.Headers[HeaderNames.Range]; var rawRangeHeader = context.Request.Headers[HeaderNames.Range];
if (StringValues.IsNullOrEmpty(rawRangeHeader)) if (StringValues.IsNullOrEmpty(rawRangeHeader))
{ {
return null; return (false, null);
} }
// Perf: Check for a single entry before parsing it // Perf: Check for a single entry before parsing it
@ -38,98 +44,44 @@ namespace Microsoft.AspNetCore.Internal
// The spec allows for multiple ranges but we choose not to support them because the client may request // The spec allows for multiple ranges but we choose not to support them because the client may request
// very strange ranges (e.g. each byte separately, overlapping ranges, etc.) that could negatively // very strange ranges (e.g. each byte separately, overlapping ranges, etc.) that could negatively
// impact the server. Ignore the header and serve the response normally. // impact the server. Ignore the header and serve the response normally.
return null; return (false, null);
} }
var rangeHeader = requestHeaders.Range; var rangeHeader = requestHeaders.Range;
if (rangeHeader == null) if (rangeHeader == null)
{ {
// Invalid // Invalid
return null; return (false, null);
} }
// Already verified above // Already verified above
Debug.Assert(rangeHeader.Ranges.Count == 1); Debug.Assert(rangeHeader.Ranges.Count == 1);
// 14.27 If-Range var ranges = rangeHeader.Ranges;
var ifRangeHeader = requestHeaders.IfRange;
if (ifRangeHeader != null)
{
// If the validator given in the If-Range header field matches the
// current validator for the selected representation of the target
// resource, then the server SHOULD process the Range header field as
// requested. If the validator does not match, the server MUST ignore
// the Range header field.
bool ignoreRangeHeader = false;
if (ifRangeHeader.LastModified.HasValue)
{
if (lastModified.HasValue && lastModified > ifRangeHeader.LastModified)
{
ignoreRangeHeader = true;
}
}
else if (etag != null && ifRangeHeader.EntityTag != null && !ifRangeHeader.EntityTag.Compare(etag, useStrongComparison: true))
{
ignoreRangeHeader = true;
}
if (ignoreRangeHeader)
{
return null;
}
}
return rangeHeader.Ranges;
}
/// <summary>
/// A helper method to normalize a collection of <see cref="RangeItemHeaderValue"/>s.
/// </summary>
/// <param name="ranges">A collection of <see cref="RangeItemHeaderValue"/> to normalize.</param>
/// <param name="length">The total length of the file representation requested.</param>
/// <returns>A normalized list of <see cref="RangeItemHeaderValue"/>s.</returns>
// 14.35.1 Byte Ranges - If a syntactically valid byte-range-set includes at least one byte-range-spec whose
// first-byte-pos is less than the current length of the entity-body, or at least one suffix-byte-range-spec
// with a non-zero suffix-length, then the byte-range-set is satisfiable.
// Adjusts ranges to be absolute and within bounds.
public static IList<RangeItemHeaderValue> NormalizeRanges(ICollection<RangeItemHeaderValue> ranges, long length)
{
if (ranges == null) if (ranges == null)
{ {
return null; return (false, null);
} }
if (ranges.Count == 0) if (ranges.Count == 0)
{ {
return Array.Empty<RangeItemHeaderValue>(); return (true, null);
} }
if (length == 0) if (length == 0)
{ {
return Array.Empty<RangeItemHeaderValue>(); return (true, null);
} }
var normalizedRanges = new List<RangeItemHeaderValue>(ranges.Count); // Normalize the ranges
foreach (var range in ranges) var range = NormalizeRange(ranges.SingleOrDefault(), length);
{
var normalizedRange = NormalizeRange(range, length);
if (normalizedRange != null) // Return the single range
{ return (true, range);
normalizedRanges.Add(normalizedRange);
}
}
return normalizedRanges;
} }
/// <summary> // Internal for testing
/// A helper method to normalize a <see cref="RangeItemHeaderValue"/>. internal static RangeItemHeaderValue NormalizeRange(RangeItemHeaderValue range, long length)
/// </summary>
/// <param name="range">The <see cref="RangeItemHeaderValue"/> to normalize.</param>
/// <param name="length">The total length of the file representation requested.</param>
/// <returns>A normalized <see cref="RangeItemHeaderValue"/>.</returns>
public static RangeItemHeaderValue NormalizeRange(RangeItemHeaderValue range, long length)
{ {
var start = range.From; var start = range.From;
var end = range.To; var end = range.To;
@ -161,8 +113,7 @@ namespace Microsoft.AspNetCore.Internal
end = start + bytes - 1; end = start + bytes - 1;
} }
var normalizedRange = new RangeItemHeaderValue(start, end); return new RangeItemHeaderValue(start, end);
return normalizedRange;
} }
} }
} }

View File

@ -2,8 +2,6 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@ -49,7 +47,8 @@ namespace Microsoft.AspNetCore.StaticFiles
private PreconditionState _ifModifiedSinceState; private PreconditionState _ifModifiedSinceState;
private PreconditionState _ifUnmodifiedSinceState; private PreconditionState _ifUnmodifiedSinceState;
private IList<RangeItemHeaderValue> _ranges; private RangeItemHeaderValue _range;
private bool _isRangeRequest;
public StaticFileContext(HttpContext context, StaticFileOptions options, PathString matchUrl, ILogger logger, IFileProvider fileProvider, IContentTypeProvider contentTypeProvider) public StaticFileContext(HttpContext context, StaticFileOptions options, PathString matchUrl, ILogger logger, IFileProvider fileProvider, IContentTypeProvider contentTypeProvider)
{ {
@ -77,7 +76,8 @@ namespace Microsoft.AspNetCore.StaticFiles
_ifNoneMatchState = PreconditionState.Unspecified; _ifNoneMatchState = PreconditionState.Unspecified;
_ifModifiedSinceState = PreconditionState.Unspecified; _ifModifiedSinceState = PreconditionState.Unspecified;
_ifUnmodifiedSinceState = PreconditionState.Unspecified; _ifUnmodifiedSinceState = PreconditionState.Unspecified;
_ranges = null; _range = null;
_isRangeRequest = false;
} }
internal enum PreconditionState internal enum PreconditionState
@ -85,7 +85,7 @@ namespace Microsoft.AspNetCore.StaticFiles
Unspecified, Unspecified,
NotModified, NotModified,
ShouldProcess, ShouldProcess,
PreconditionFailed, PreconditionFailed
} }
public bool IsHeadMethod public bool IsHeadMethod
@ -95,7 +95,7 @@ namespace Microsoft.AspNetCore.StaticFiles
public bool IsRangeRequest public bool IsRangeRequest
{ {
get { return _ranges != null; } get { return _isRangeRequest; }
} }
public string SubPath public string SubPath
@ -162,6 +162,8 @@ namespace Microsoft.AspNetCore.StaticFiles
ComputeIfModifiedSince(); ComputeIfModifiedSince();
ComputeRange(); ComputeRange();
ComputeIfRange();
} }
private void ComputeIfMatch() private void ComputeIfMatch()
@ -218,6 +220,31 @@ namespace Microsoft.AspNetCore.StaticFiles
} }
} }
private void ComputeIfRange()
{
// 14.27 If-Range
var ifRangeHeader = _requestHeaders.IfRange;
if (ifRangeHeader != null)
{
// If the validator given in the If-Range header field matches the
// current validator for the selected representation of the target
// resource, then the server SHOULD process the Range header field as
// requested. If the validator does not match, the server MUST ignore
// the Range header field.
if (ifRangeHeader.LastModified.HasValue)
{
if (_lastModified !=null && _lastModified > ifRangeHeader.LastModified)
{
_isRangeRequest = false;
}
}
else if (_etag != null && ifRangeHeader.EntityTag != null && !ifRangeHeader.EntityTag.Compare(_etag, useStrongComparison: true))
{
_isRangeRequest = false;
}
}
}
private void ComputeRange() private void ComputeRange()
{ {
// 14.35 Range // 14.35 Range
@ -230,8 +257,7 @@ namespace Microsoft.AspNetCore.StaticFiles
return; return;
} }
var parsedRange = RangeHelper.ParseRange(_context, _requestHeaders, _lastModified, _etag); (_isRangeRequest, _range) = RangeHelper.ParseRange(_context, _requestHeaders, _length);
_ranges = RangeHelper.NormalizeRanges(parsedRange, _length);
} }
public void ApplyResponseHeaders(int statusCode) public void ApplyResponseHeaders(int statusCode)
@ -322,13 +348,7 @@ namespace Microsoft.AspNetCore.StaticFiles
// When there is only a single range the bytes are sent directly in the body. // When there is only a single range the bytes are sent directly in the body.
internal async Task SendRangeAsync() internal async Task SendRangeAsync()
{ {
bool rangeNotSatisfiable = false; if (_range == null)
if (_ranges.Count == 0)
{
rangeNotSatisfiable = true;
}
if (rangeNotSatisfiable)
{ {
// 14.16 Content-Range - A server sending a response with status code 416 (Requested range not satisfiable) // 14.16 Content-Range - A server sending a response with status code 416 (Requested range not satisfiable)
// SHOULD include a Content-Range field with a byte-range-resp-spec of "*". The instance-length specifies // SHOULD include a Content-Range field with a byte-range-resp-spec of "*". The instance-length specifies
@ -340,11 +360,8 @@ namespace Microsoft.AspNetCore.StaticFiles
return; return;
} }
// Multi-range is not supported.
Debug.Assert(_ranges.Count == 1);
long start, length; long start, length;
_responseHeaders.ContentRange = ComputeContentRange(_ranges[0], out start, out length); _responseHeaders.ContentRange = ComputeContentRange(_range, out start, out length);
_response.ContentLength = length; _response.ContentLength = length;
ApplyResponseHeaders(Constants.Status206PartialContent); ApplyResponseHeaders(Constants.Status206PartialContent);

View File

@ -70,7 +70,7 @@ namespace Microsoft.AspNetCore.StaticFiles
public Task Invoke(HttpContext context) public Task Invoke(HttpContext context)
{ {
var fileContext = new StaticFileContext(context, _options, _matchUrl, _logger, _fileProvider, _contentTypeProvider); var fileContext = new StaticFileContext(context, _options, _matchUrl, _logger, _fileProvider, _contentTypeProvider);
if (!fileContext.ValidateMethod()) if (!fileContext.ValidateMethod())
{ {
_logger.LogRequestMethodNotSupported(context.Request.Method); _logger.LogRequestMethodNotSupported(context.Request.Method);
@ -88,10 +88,9 @@ namespace Microsoft.AspNetCore.StaticFiles
_logger.LogFileNotFound(fileContext.SubPath); _logger.LogFileNotFound(fileContext.SubPath);
} }
else else
{ {
// If we get here, we can try to serve the file // If we get here, we can try to serve the file
fileContext.ComprehendRequestHeaders(); fileContext.ComprehendRequestHeaders();
switch (fileContext.GetPreconditionState()) switch (fileContext.GetPreconditionState())
{ {
case StaticFileContext.PreconditionState.Unspecified: case StaticFileContext.PreconditionState.Unspecified:
@ -104,10 +103,8 @@ namespace Microsoft.AspNetCore.StaticFiles
{ {
return fileContext.SendRangeAsync(); return fileContext.SendRangeAsync();
} }
_logger.LogFileServed(fileContext.SubPath, fileContext.PhysicalPath); _logger.LogFileServed(fileContext.SubPath, fileContext.PhysicalPath);
return fileContext.SendAsync(); return fileContext.SendAsync();
case StaticFileContext.PreconditionState.NotModified: case StaticFileContext.PreconditionState.NotModified:
_logger.LogPathNotModified(fileContext.SubPath); _logger.LogPathNotModified(fileContext.SubPath);
return fileContext.SendStatusAsync(Constants.Status304NotModified); return fileContext.SendStatusAsync(Constants.Status304NotModified);

View File

@ -1,8 +1,6 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using Xunit; using Xunit;
@ -11,134 +9,50 @@ namespace Microsoft.AspNetCore.Internal
{ {
public class RangeHelperTests public class RangeHelperTests
{ {
[Fact]
public void NormalizeRanges_ReturnsEmptyArrayWhenRangeCountZero()
{
// Arrange
var ranges = new List<RangeItemHeaderValue>();
// Act
var normalizedRanges = RangeHelper.NormalizeRanges(ranges, 2);
// Assert
Assert.Empty(normalizedRanges);
}
[Fact]
public void NormalizeRanges_ReturnsEmptyArrayWhenLengthZero()
{
// Arrange
var ranges = new[]
{
new RangeItemHeaderValue(0, 0),
};
// Act
var normalizedRanges = RangeHelper.NormalizeRanges(ranges, 0);
// Assert
Assert.Empty(normalizedRanges);
}
[Theory] [Theory]
[InlineData(1, 2)] [InlineData(1, 2)]
[InlineData(2, 3)] [InlineData(2, 3)]
public void NormalizeRanges_SkipsItemWhenRangeStartEqualOrGreaterThanLength(long start, long end) public void NormalizeRange_ReturnsNullWhenRangeStartEqualsOrGreaterThanLength(long start, long end)
{ {
// Arrange // Arrange & Act
var ranges = new[] var normalizedRange = RangeHelper.NormalizeRange(new RangeItemHeaderValue(start, end), 1);
{
new RangeItemHeaderValue(start, end),
};
// Act
var normalizedRanges = RangeHelper.NormalizeRanges(ranges, 1);
// Assert // Assert
Assert.Empty(normalizedRanges); Assert.Null(normalizedRange);
} }
[Fact] [Fact]
public void NormalizeRanges_SkipsItemWhenRangeEndEqualsZero() public void NormalizeRange_ReturnsNullWhenRangeEndEqualsZero()
{ {
// Arrange // Arrange & Act
var ranges = new[] var normalizedRange = RangeHelper.NormalizeRange(new RangeItemHeaderValue(null, 0), 1);
{
new RangeItemHeaderValue(null, 0),
};
// Act
var normalizedRanges = RangeHelper.NormalizeRanges(ranges, 1);
// Assert // Assert
Assert.Empty(normalizedRanges); Assert.Null(normalizedRange);
} }
[Theory] [Theory]
[InlineData(0, null, 0, 2)] [InlineData(0, null, 0, 2)]
[InlineData(0, 0, 0, 0)] [InlineData(0, 0, 0, 0)]
public void NormalizeRanges_ReturnsNormalizedRange(long? start, long? end, long? normalizedStart, long? normalizedEnd) public void NormalizeRange_ReturnsNormalizedRange(long? start, long? end, long? normalizedStart, long? normalizedEnd)
{ {
// Arrange // Arrange & Act
var ranges = new[] var normalizedRange = RangeHelper.NormalizeRange(new RangeItemHeaderValue(start, end), 3);
{
new RangeItemHeaderValue(start, end),
};
// Act
var normalizedRanges = RangeHelper.NormalizeRanges(ranges, 3);
// Assert // Assert
var range = Assert.Single(normalizedRanges); Assert.Equal(normalizedStart, normalizedRange.From);
Assert.Equal(normalizedStart, range.From); Assert.Equal(normalizedEnd, normalizedRange.To);
Assert.Equal(normalizedEnd, range.To);
} }
[Fact] [Fact]
public void NormalizeRanges_ReturnsRangeWithNoChange() public void NormalizeRange_ReturnsRangeWithNoChange()
{ {
// Arrange // Arrange & Act
var ranges = new[] var normalizedRange = RangeHelper.NormalizeRange(new RangeItemHeaderValue(1, 3), 4);
{
new RangeItemHeaderValue(1, 3),
};
// Act
var normalizedRanges = RangeHelper.NormalizeRanges(ranges, 4);
// Assert // Assert
var range = Assert.Single(normalizedRanges); Assert.Equal(1, normalizedRange.From);
Assert.Equal(1, range.From); Assert.Equal(3, normalizedRange.To);
Assert.Equal(3, range.To);
}
[Theory]
[InlineData(0, null, 0, 2)]
[InlineData(0, 0, 0, 0)]
public void NormalizeRanges_MultipleRanges_ReturnsNormalizedRange(long? start, long? end, long? normalizedStart, long? normalizedEnd)
{
// Arrange
var ranges = new[]
{
new RangeItemHeaderValue(start, end),
new RangeItemHeaderValue(1, 2),
};
// Act
var normalizedRanges = RangeHelper.NormalizeRanges(ranges, 3);
// Assert
Assert.Collection(normalizedRanges,
range =>
{
Assert.Equal(normalizedStart, range.From);
Assert.Equal(normalizedEnd, range.To);
},
range =>
{
Assert.Equal(1, range.From);
Assert.Equal(2, range.To);
});
} }
[Theory] [Theory]
@ -151,9 +65,10 @@ namespace Microsoft.AspNetCore.Internal
httpContext.Request.Headers[HeaderNames.Range] = range; httpContext.Request.Headers[HeaderNames.Range] = range;
// Act // Act
var parsedRangeResult = RangeHelper.ParseRange(httpContext, httpContext.Request.GetTypedHeaders(), new DateTimeOffset(), null); var (isRangeRequest, parsedRangeResult) = RangeHelper.ParseRange(httpContext, httpContext.Request.GetTypedHeaders(), 10);
// Assert // Assert
Assert.False(isRangeRequest);
Assert.Null(parsedRangeResult); Assert.Null(parsedRangeResult);
} }
@ -167,43 +82,10 @@ namespace Microsoft.AspNetCore.Internal
httpContext.Request.Headers[HeaderNames.Range] = range; httpContext.Request.Headers[HeaderNames.Range] = range;
// Act // Act
var parsedRangeResult = RangeHelper.ParseRange(httpContext, httpContext.Request.GetTypedHeaders(), new DateTimeOffset(), null); var (isRangeRequest, parsedRangeResult) = RangeHelper.ParseRange(httpContext, httpContext.Request.GetTypedHeaders(), 10);
// Assert
Assert.Null(parsedRangeResult);
}
[Fact]
public void ParseRange_ReturnsNullWhenLastModifiedGreaterThanIfRangeHeaderLastModified()
{
// Arrange
var httpContext = new DefaultHttpContext();
var range = new RangeHeaderValue(1, 2);
httpContext.Request.Headers[HeaderNames.Range] = range.ToString();
var lastModified = new RangeConditionHeaderValue(DateTime.Now);
httpContext.Request.Headers[HeaderNames.IfRange] = lastModified.ToString();
// Act
var parsedRangeResult = RangeHelper.ParseRange(httpContext, httpContext.Request.GetTypedHeaders(), DateTime.Now.AddMilliseconds(2), null);
// Assert
Assert.Null(parsedRangeResult);
}
[Fact]
public void ParseRange_ReturnsNullWhenETagNotEqualToIfRangeHeaderEntityTag()
{
// Arrange
var httpContext = new DefaultHttpContext();
var range = new RangeHeaderValue(1, 2);
httpContext.Request.Headers[HeaderNames.Range] = range.ToString();
var etag = new RangeConditionHeaderValue("\"tag\"");
httpContext.Request.Headers[HeaderNames.IfRange] = etag.ToString();
// Act
var parsedRangeResult = RangeHelper.ParseRange(httpContext, httpContext.Request.GetTypedHeaders(), DateTime.Now, new EntityTagHeaderValue("\"etag\""));
// Assert // Assert
Assert.False(isRangeRequest);
Assert.Null(parsedRangeResult); Assert.Null(parsedRangeResult);
} }
@ -214,35 +96,12 @@ namespace Microsoft.AspNetCore.Internal
var httpContext = new DefaultHttpContext(); var httpContext = new DefaultHttpContext();
var range = new RangeHeaderValue(1, 2); var range = new RangeHeaderValue(1, 2);
httpContext.Request.Headers[HeaderNames.Range] = range.ToString(); httpContext.Request.Headers[HeaderNames.Range] = range.ToString();
var lastModified = new RangeConditionHeaderValue(DateTime.Now);
httpContext.Request.Headers[HeaderNames.IfRange] = lastModified.ToString();
var etag = new RangeConditionHeaderValue("\"etag\"");
httpContext.Request.Headers[HeaderNames.IfRange] = etag.ToString();
// Act // Act
var parsedRangeResult = RangeHelper.ParseRange(httpContext, httpContext.Request.GetTypedHeaders(), DateTime.Now, new EntityTagHeaderValue("\"etag\"")); var (isRangeRequest, parsedRange) = RangeHelper.ParseRange(httpContext, httpContext.Request.GetTypedHeaders(), 4);
// Assert // Assert
var parsedRange = Assert.Single(parsedRangeResult); Assert.True(isRangeRequest);
Assert.Equal(1, parsedRange.From);
Assert.Equal(2, parsedRange.To);
}
[Fact]
public void ParseRange_ReturnsRangeWhenLastModifiedAndEtagNull()
{
// Arrange
var httpContext = new DefaultHttpContext();
var range = new RangeHeaderValue(1, 2);
httpContext.Request.Headers[HeaderNames.Range] = range.ToString();
var lastModified = new RangeConditionHeaderValue(DateTime.Now);
httpContext.Request.Headers[HeaderNames.IfRange] = lastModified.ToString();
// Act
var parsedRangeResult = RangeHelper.ParseRange(httpContext, httpContext.Request.GetTypedHeaders());
// Assert
var parsedRange = Assert.Single(parsedRangeResult);
Assert.Equal(1, parsedRange.From); Assert.Equal(1, parsedRange.From);
Assert.Equal(2, parsedRange.To); Assert.Equal(2, parsedRange.To);
} }