From 52df2512365e3bb567dbfc180ad40853ed3db3ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:21:04 +0000 Subject: [PATCH 1/2] feat: add IAsyncEnumerable decode/encode and zero-allocation IDuplexPipe support Co-authored-by: petesramek <2333452+petesramek@users.noreply.github.com> Agent-Logs-Url: https://github.com/petesramek/polyline-algorithm-csharp/sessions/8bac762b-8608-43c2-9c46-e9485c2acf63 --- .../Abstraction/AbstractPolylineDecoder.cs | 137 ++++++++++++++- .../Abstraction/AbstractPolylineEncoder.cs | 91 +++++++++- .../Abstraction/IAsyncPolylineDecoder.cs | 29 ++++ .../Abstraction/IAsyncPolylineEncoder.cs | 30 ++++ .../Abstraction/IPolylinePipeDecoder.cs | 38 ++++ .../Abstraction/IPolylinePipeEncoder.cs | 42 +++++ .../AsyncPolylineDecoderExtensions.cs | 111 ++++++++++++ .../AsyncPolylineEncoderExtensions.cs | 65 +++++++ .../PolylineAlgorithm.csproj | 1 + src/PolylineAlgorithm/PolylineEncoding.cs | 80 +++++++++ src/PolylineAlgorithm/PublicAPI.Shipped.txt | 80 ++++++++- src/PolylineAlgorithm/PublicAPI.Unshipped.txt | 20 ++- .../AsyncPolylineDecoderTest.cs | 163 ++++++++++++++++++ .../AsyncPolylineEncoderTest.cs | 150 ++++++++++++++++ .../PolylinePipeDecoderTest.cs | 131 ++++++++++++++ .../PolylinePipeEncoderTest.cs | 139 +++++++++++++++ 16 files changed, 1303 insertions(+), 4 deletions(-) create mode 100644 src/PolylineAlgorithm/Abstraction/IAsyncPolylineDecoder.cs create mode 100644 src/PolylineAlgorithm/Abstraction/IAsyncPolylineEncoder.cs create mode 100644 src/PolylineAlgorithm/Abstraction/IPolylinePipeDecoder.cs create mode 100644 src/PolylineAlgorithm/Abstraction/IPolylinePipeEncoder.cs create mode 100644 src/PolylineAlgorithm/Extensions/AsyncPolylineDecoderExtensions.cs create mode 100644 src/PolylineAlgorithm/Extensions/AsyncPolylineEncoderExtensions.cs create mode 100644 tests/PolylineAlgorithm.Tests/AsyncPolylineDecoderTest.cs create mode 100644 tests/PolylineAlgorithm.Tests/AsyncPolylineEncoderTest.cs create mode 100644 tests/PolylineAlgorithm.Tests/PolylinePipeDecoderTest.cs create mode 100644 tests/PolylineAlgorithm.Tests/PolylinePipeEncoderTest.cs diff --git a/src/PolylineAlgorithm/Abstraction/AbstractPolylineDecoder.cs b/src/PolylineAlgorithm/Abstraction/AbstractPolylineDecoder.cs index b15766b5..d8c66cf0 100644 --- a/src/PolylineAlgorithm/Abstraction/AbstractPolylineDecoder.cs +++ b/src/PolylineAlgorithm/Abstraction/AbstractPolylineDecoder.cs @@ -11,7 +11,13 @@ namespace PolylineAlgorithm.Abstraction; using PolylineAlgorithm.Internal.Logging; using PolylineAlgorithm.Properties; using System; +using System.Buffers; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.IO.Pipelines; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; /// /// Decodes encoded polyline strings into sequences of geographic coordinates. @@ -20,7 +26,7 @@ namespace PolylineAlgorithm.Abstraction; /// /// This abstract class provides a base implementation for decoding polylines, allowing subclasses to define how to handle specific polyline formats. /// -public abstract class AbstractPolylineDecoder : IPolylineDecoder { +public abstract class AbstractPolylineDecoder : IPolylineDecoder, IAsyncPolylineDecoder, IPolylinePipeDecoder { /// /// Initializes a new instance of the class with default encoding options. /// @@ -138,6 +144,135 @@ static void ValidateEmptySequence(ILogger protected abstract ReadOnlyMemory GetReadOnlyMemory(TPolyline polyline); + /// + /// Asynchronously decodes the specified encoded polyline into a sequence of geographic coordinates by + /// iterating the synchronous implementation and checking the cancellation token + /// between each yielded coordinate. + /// + /// + /// The instance containing the encoded polyline string to decode. + /// + /// + /// A to observe while iterating. + /// + /// + /// An of representing the decoded + /// latitude and longitude pairs. + /// + public async IAsyncEnumerable DecodeAsync( + TPolyline polyline, + [EnumeratorCancellation] CancellationToken cancellationToken) { + + foreach (TCoordinate coordinate in Decode(polyline)) { + cancellationToken.ThrowIfCancellationRequested(); + yield return coordinate; + } + + await Task.CompletedTask.ConfigureAwait(false); + } + + /// + /// Asynchronously decodes encoded polyline bytes read from into a sequence of + /// geographic coordinates with zero intermediate allocations. + /// + /// + /// The method processes the pipe in chunks using to handle multi-segment + /// buffers transparently. The pipe reader is not completed by this method. + /// + /// + /// The from which the encoded polyline bytes are consumed. + /// + /// + /// A to observe while waiting for data from the pipe. + /// + /// + /// An of representing the decoded + /// latitude and longitude pairs. + /// + /// + /// Thrown when is . + /// + public async IAsyncEnumerable DecodeAsync( + PipeReader reader, + [EnumeratorCancellation] CancellationToken cancellationToken) { + + if (reader is null) { + throw new ArgumentNullException(nameof(reader)); + } + + int latitude = 0; + int longitude = 0; + bool firstRead = true; + + while (true) { + ReadResult result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + ReadOnlySequence buffer = result.Buffer; + + if (firstRead && buffer.IsEmpty && result.IsCompleted) { + throw new ArgumentException( + string.Format(ExceptionMessageResource.PolylineCannotBeShorterThanExceptionMessage, 0), + nameof(reader)); + } + + firstRead = false; + + // Process the buffer synchronously so that the SequenceReader (a ref struct) never lives + // across a yield boundary. + var decoded = new List(); + (SequencePosition consumed, latitude, longitude) = ProcessPipeBuffer(buffer, latitude, longitude, decoded); + + foreach (TCoordinate coordinate in decoded) { + yield return coordinate; + } + + // Tell the pipe how far we have consumed and examined. + reader.AdvanceTo(consumed, buffer.End); + + if (result.IsCompleted) { + break; + } + } + } + + /// + /// Synchronously processes a pipe buffer, decoding as many complete + /// coordinate pairs as possible and returning the updated variance state and the consumed position. + /// is used here because this method is not an async iterator + /// and therefore the ref-struct constraint does not apply. + /// + private (SequencePosition consumed, int latitude, int longitude) ProcessPipeBuffer( + ReadOnlySequence buffer, + int latitude, + int longitude, + List results) { + + var sequenceReader = new SequenceReader(buffer); + SequencePosition consumed = buffer.Start; + + while (!sequenceReader.End) { + // Save state before attempting to decode a coordinate pair so we can roll back if only + // part of the pair is available in the current buffer. + SequencePosition pairStart = sequenceReader.Position; + int savedLatitude = latitude; + int savedLongitude = longitude; + + if (!PolylineEncoding.TryReadValue(ref latitude, ref sequenceReader) + || !PolylineEncoding.TryReadValue(ref longitude, ref sequenceReader)) { + + latitude = savedLatitude; + longitude = savedLongitude; + break; + } + + consumed = sequenceReader.Position; + results.Add(CreateCoordinate( + PolylineEncoding.Denormalize(latitude, CoordinateValueType.Latitude), + PolylineEncoding.Denormalize(longitude, CoordinateValueType.Longitude))); + } + + return (consumed, latitude, longitude); + } + /// /// Creates a coordinate instance from the given latitude and longitude values. /// diff --git a/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs b/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs index 6ea869bd..6058a5d3 100644 --- a/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs +++ b/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs @@ -14,6 +14,10 @@ namespace PolylineAlgorithm.Abstraction; using System.Collections; using System.Collections.Generic; using System.Diagnostics; +using System.IO.Pipelines; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; /// /// Provides functionality to encode a collection of geographic coordinates into an encoded polyline string. @@ -22,7 +26,7 @@ namespace PolylineAlgorithm.Abstraction; /// /// This abstract class serves as a base for specific polyline encoders, allowing customization of the encoding process. /// -public abstract class AbstractPolylineEncoder : IPolylineEncoder { +public abstract class AbstractPolylineEncoder : IPolylineEncoder, IAsyncPolylineEncoder, IPolylinePipeEncoder { /// /// Initializes a new instance of the class with default encoding options. /// @@ -217,5 +221,90 @@ static void ValidateBuffer(ILogger protected abstract double GetLatitude(TCoordinate current); + + /// + /// Asynchronously encodes a sequence of geographic coordinates into an encoded polyline by collecting all + /// coordinates from the and then encoding them synchronously. + /// + /// + /// The asynchronous collection of instances to encode. + /// + /// + /// A to observe while collecting coordinates. + /// + /// + /// A containing the encoded . + /// + /// + /// Thrown when is . + /// + public async ValueTask EncodeAsync( + IAsyncEnumerable coordinates, + CancellationToken cancellationToken) { + + if (coordinates is null) { + throw new ArgumentNullException(nameof(coordinates)); + } + + var list = new List(); + + await foreach (TCoordinate coordinate in coordinates.WithCancellation(cancellationToken).ConfigureAwait(false)) { + list.Add(coordinate); + } + + return Encode(list); + } + + /// + /// Asynchronously encodes a sequence of geographic coordinates and writes the encoded polyline bytes directly + /// into with zero intermediate allocations. + /// + /// + /// Each coordinate pair is encoded directly into the 's buffer using + /// , + /// avoiding intermediate string or character-array allocations. The writer is flushed periodically + /// but is not completed by this method. + /// + /// + /// The asynchronous collection of instances to encode. + /// + /// + /// The to which the encoded bytes are written. + /// + /// + /// A to observe while iterating coordinates. + /// + /// + /// A representing the asynchronous encode-and-write operation. + /// + /// + /// Thrown when or is . + /// + public async ValueTask EncodeAsync( + IAsyncEnumerable coordinates, + PipeWriter writer, + CancellationToken cancellationToken) { + + if (coordinates is null) { + throw new ArgumentNullException(nameof(coordinates)); + } + + if (writer is null) { + throw new ArgumentNullException(nameof(writer)); + } + + CoordinateVariance variance = new(); + + await foreach (TCoordinate coordinate in coordinates.WithCancellation(cancellationToken).ConfigureAwait(false)) { + variance.Next( + PolylineEncoding.Normalize(GetLatitude(coordinate), CoordinateValueType.Latitude), + PolylineEncoding.Normalize(GetLongitude(coordinate), CoordinateValueType.Longitude)); + + PolylineEncoding.WriteValue(variance.Latitude, writer); + PolylineEncoding.WriteValue(variance.Longitude, writer); + } + + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } } diff --git a/src/PolylineAlgorithm/Abstraction/IAsyncPolylineDecoder.cs b/src/PolylineAlgorithm/Abstraction/IAsyncPolylineDecoder.cs new file mode 100644 index 00000000..2b08c35c --- /dev/null +++ b/src/PolylineAlgorithm/Abstraction/IAsyncPolylineDecoder.cs @@ -0,0 +1,29 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.Abstraction; + +using System.Collections.Generic; +using System.Threading; + +/// +/// Defines a contract for asynchronously decoding an encoded polyline into a sequence of geographic coordinates. +/// +public interface IAsyncPolylineDecoder { + /// + /// Asynchronously decodes the specified encoded polyline into a sequence of geographic coordinates. + /// + /// + /// The instance containing the encoded polyline string to decode. + /// + /// + /// A to observe while waiting for the task to complete. + /// + /// + /// An of representing the decoded + /// latitude and longitude pairs, streamed asynchronously. + /// + IAsyncEnumerable DecodeAsync(TPolyline polyline, CancellationToken cancellationToken); +} diff --git a/src/PolylineAlgorithm/Abstraction/IAsyncPolylineEncoder.cs b/src/PolylineAlgorithm/Abstraction/IAsyncPolylineEncoder.cs new file mode 100644 index 00000000..3de557ff --- /dev/null +++ b/src/PolylineAlgorithm/Abstraction/IAsyncPolylineEncoder.cs @@ -0,0 +1,30 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.Abstraction; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Defines a contract for asynchronously encoding a sequence of geographic coordinates into an encoded polyline. +/// +public interface IAsyncPolylineEncoder { + /// + /// Asynchronously encodes a sequence of geographic coordinates into an encoded polyline representation. + /// + /// + /// The asynchronous collection of instances to encode into a polyline. + /// + /// + /// A to observe while waiting for the task to complete. + /// + /// + /// A that represents the asynchronous operation, containing a + /// with the encoded polyline string that represents the input coordinates. + /// + ValueTask EncodeAsync(IAsyncEnumerable coordinates, CancellationToken cancellationToken); +} diff --git a/src/PolylineAlgorithm/Abstraction/IPolylinePipeDecoder.cs b/src/PolylineAlgorithm/Abstraction/IPolylinePipeDecoder.cs new file mode 100644 index 00000000..8eab2bf8 --- /dev/null +++ b/src/PolylineAlgorithm/Abstraction/IPolylinePipeDecoder.cs @@ -0,0 +1,38 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.Abstraction; + +using System.Collections.Generic; +using System.IO.Pipelines; +using System.Threading; + +/// +/// Defines a contract for zero-allocation decoding of an encoded polyline streamed from a +/// into a sequence of geographic coordinates. +/// +/// +/// Implementations operate directly on pipe buffers to avoid intermediate string or character-array allocations, +/// making this interface well-suited for high-throughput or memory-constrained scenarios where the encoded +/// polyline arrives over a network stream or another source. +/// +public interface IPolylinePipeDecoder { + /// + /// Asynchronously decodes encoded polyline bytes read from into a sequence of + /// geographic coordinates, operating with zero intermediate allocations. + /// + /// + /// The from which the encoded polyline bytes are consumed. + /// The reader is not completed by this method; the caller is responsible for its lifetime. + /// + /// + /// A to observe while waiting for the task to complete. + /// + /// + /// An of representing the decoded + /// latitude and longitude pairs, streamed asynchronously as they become available from the pipe. + /// + IAsyncEnumerable DecodeAsync(PipeReader reader, CancellationToken cancellationToken); +} diff --git a/src/PolylineAlgorithm/Abstraction/IPolylinePipeEncoder.cs b/src/PolylineAlgorithm/Abstraction/IPolylinePipeEncoder.cs new file mode 100644 index 00000000..f58e950b --- /dev/null +++ b/src/PolylineAlgorithm/Abstraction/IPolylinePipeEncoder.cs @@ -0,0 +1,42 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.Abstraction; + +using System.Collections.Generic; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Defines a contract for zero-allocation encoding of geographic coordinates written directly to a +/// . +/// +/// +/// Implementations write encoded polyline bytes directly into pipe buffers, avoiding intermediate string or +/// character-array allocations and making this interface well-suited for high-throughput or memory-constrained +/// scenarios where the encoded result must be streamed over a network or to another +/// consumer. +/// +public interface IPolylinePipeEncoder { + /// + /// Asynchronously encodes a sequence of geographic coordinates and writes the encoded polyline bytes directly + /// into , operating with zero intermediate allocations. + /// + /// + /// The asynchronous collection of instances to encode. + /// + /// + /// The to which the encoded polyline bytes are written. + /// The writer is flushed but not completed by this method; the caller is responsible for its lifetime. + /// + /// + /// A to observe while waiting for the task to complete. + /// + /// + /// A that represents the asynchronous encode-and-write operation. + /// + ValueTask EncodeAsync(IAsyncEnumerable coordinates, PipeWriter writer, CancellationToken cancellationToken); +} diff --git a/src/PolylineAlgorithm/Extensions/AsyncPolylineDecoderExtensions.cs b/src/PolylineAlgorithm/Extensions/AsyncPolylineDecoderExtensions.cs new file mode 100644 index 00000000..3de67579 --- /dev/null +++ b/src/PolylineAlgorithm/Extensions/AsyncPolylineDecoderExtensions.cs @@ -0,0 +1,111 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.Extensions; + +using PolylineAlgorithm; +using PolylineAlgorithm.Abstraction; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; + +/// +/// Provides extension methods for the interface and +/// for adapting synchronous decoder types to support asynchronous decoding from common input representations. +/// +public static class AsyncPolylineDecoderExtensions { + /// + /// Asynchronously decodes an encoded polyline string into a sequence of geographic coordinates. + /// + /// + /// The instance used to perform the decoding. + /// + /// + /// The encoded polyline string to decode. + /// + /// + /// A to observe while iterating. + /// + /// + /// An of representing the decoded pairs. + /// + /// + /// Thrown when is . + /// + public static IAsyncEnumerable DecodeAsync( + this IAsyncPolylineDecoder decoder, + string polyline, + CancellationToken cancellationToken) { + + if (decoder is null) { + throw new ArgumentNullException(nameof(decoder)); + } + + return decoder.DecodeAsync(Polyline.FromString(polyline), cancellationToken); + } + + /// + /// Asynchronously decodes an encoded polyline represented as a character array into a sequence of geographic + /// coordinates. + /// + /// + /// The instance used to perform the decoding. + /// + /// + /// The encoded polyline as a character array to decode. + /// + /// + /// A to observe while iterating. + /// + /// + /// An of representing the decoded pairs. + /// + /// + /// Thrown when is . + /// + public static IAsyncEnumerable DecodeAsync( + this IAsyncPolylineDecoder decoder, + char[] polyline, + CancellationToken cancellationToken) { + + if (decoder is null) { + throw new ArgumentNullException(nameof(decoder)); + } + + return decoder.DecodeAsync(Polyline.FromCharArray(polyline), cancellationToken); + } + + /// + /// Asynchronously decodes an encoded polyline represented as a read-only memory of characters into a sequence + /// of geographic coordinates. + /// + /// + /// The instance used to perform the decoding. + /// + /// + /// The encoded polyline as a read-only memory of characters to decode. + /// + /// + /// A to observe while iterating. + /// + /// + /// An of representing the decoded pairs. + /// + /// + /// Thrown when is . + /// + public static IAsyncEnumerable DecodeAsync( + this IAsyncPolylineDecoder decoder, + ReadOnlyMemory polyline, + CancellationToken cancellationToken) { + + if (decoder is null) { + throw new ArgumentNullException(nameof(decoder)); + } + + return decoder.DecodeAsync(Polyline.FromMemory(polyline), cancellationToken); + } +} diff --git a/src/PolylineAlgorithm/Extensions/AsyncPolylineEncoderExtensions.cs b/src/PolylineAlgorithm/Extensions/AsyncPolylineEncoderExtensions.cs new file mode 100644 index 00000000..7b0da2be --- /dev/null +++ b/src/PolylineAlgorithm/Extensions/AsyncPolylineEncoderExtensions.cs @@ -0,0 +1,65 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.Extensions; + +using PolylineAlgorithm; +using PolylineAlgorithm.Abstraction; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Provides extension methods for the interface. +/// +public static class AsyncPolylineEncoderExtensions { + /// + /// Asynchronously encodes a collection of instances into an encoded polyline by + /// wrapping the synchronous collection as an . + /// + /// + /// The instance used to perform the encoding. + /// + /// + /// The collection of objects to encode. + /// + /// + /// A to observe while encoding. + /// + /// + /// A containing the encoded . + /// + /// + /// Thrown when or is . + /// + public static ValueTask EncodeAsync( + this IAsyncPolylineEncoder encoder, + IEnumerable coordinates, + CancellationToken cancellationToken) { + + if (encoder is null) { + throw new ArgumentNullException(nameof(encoder)); + } + + if (coordinates is null) { + throw new ArgumentNullException(nameof(coordinates)); + } + + return encoder.EncodeAsync(ToAsyncEnumerable(coordinates, cancellationToken), cancellationToken); + } + + private static async IAsyncEnumerable ToAsyncEnumerable( + IEnumerable source, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { + + foreach (Coordinate item in source) { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + } + + await System.Threading.Tasks.Task.CompletedTask.ConfigureAwait(false); + } +} diff --git a/src/PolylineAlgorithm/PolylineAlgorithm.csproj b/src/PolylineAlgorithm/PolylineAlgorithm.csproj index 54b8cc74..b698fb91 100644 --- a/src/PolylineAlgorithm/PolylineAlgorithm.csproj +++ b/src/PolylineAlgorithm/PolylineAlgorithm.csproj @@ -56,6 +56,7 @@ runtime; build; native; contentfiles; analyzers + diff --git a/src/PolylineAlgorithm/PolylineEncoding.cs b/src/PolylineAlgorithm/PolylineEncoding.cs index 75100f5e..ac017489 100644 --- a/src/PolylineAlgorithm/PolylineEncoding.cs +++ b/src/PolylineAlgorithm/PolylineEncoding.cs @@ -7,6 +7,8 @@ namespace PolylineAlgorithm; using PolylineAlgorithm.Internal; using PolylineAlgorithm.Properties; using System; +using System.Buffers; +using System.IO.Pipelines; /// /// Provides methods for encoding and decoding polyline data, as well as utilities for normalizing and de-normalizing @@ -249,4 +251,82 @@ public static int Normalize(double value, CoordinateValueType type) { (CoordinateValueType.Longitude, double denormalized) when denormalized >= Defaults.Coordinate.Longitude.Min && denormalized <= Defaults.Coordinate.Longitude.Max => true, _ => false, }; + + /// + /// Attempts to read a single encoded value from a of bytes and updates the + /// variance. This overload enables zero-allocation decoding when the source is a pipe buffer. + /// + /// + /// The polyline encoding characters are all in the ASCII range, so each encoded byte maps directly to the + /// equivalent value used by . + /// If the reader reaches the end of the buffer before a complete value has been decoded, the variance is left + /// unchanged and the caller should wait for more data. + /// + /// + /// A reference to the integer that will be updated based on the value read from the buffer. + /// + /// + /// A reference to the over the pipe buffer. The reader's position is advanced + /// only for each byte that belongs to a completely decoded value. + /// + /// + /// if a complete value was successfully decoded; if the buffer + /// ended before the value was fully read (partial data — the caller should supply more bytes). + /// + internal static bool TryReadValue(ref int variance, ref SequenceReader reader) { + int chunk = 0; + int sum = 0; + int shifter = 0; + + while (reader.TryRead(out byte b)) { + chunk = b - Defaults.Algorithm.QuestionMark; + sum |= (chunk & Defaults.Algorithm.UnitSeparator) << shifter; + shifter += Defaults.Algorithm.ShiftLength; + + if (chunk < Defaults.Algorithm.Space) { + variance += (sum & 1) == 1 ? ~(sum >> 1) : sum >> 1; + return true; + } + } + + // Buffer ended before a terminating byte was found — incomplete value. + return false; + } + + /// + /// Writes a single encoded value derived from directly into an + /// of bytes. This overload enables zero-allocation encoding when the + /// destination is a pipe writer. + /// + /// + /// The method requests a span of up to 6 bytes from the writer (the maximum size of one encoded value), + /// writes the encoded bytes, and then advances the writer by the number of bytes actually used. + /// + /// + /// The integer value used to calculate the output bytes to be written. + /// + /// + /// The to which the encoded bytes are written. + /// + internal static void WriteValue(int variance, IBufferWriter writer) { + // Maximum encoded size per value is 6 bytes. + Span span = writer.GetSpan(6); + + int rem = variance << 1; + + if (variance < 0) { + rem = ~rem; + } + + int written = 0; + + while (rem >= Defaults.Algorithm.Space) { + span[written++] = (byte)((Defaults.Algorithm.Space | rem & Defaults.Algorithm.UnitSeparator) + Defaults.Algorithm.QuestionMark); + rem >>= Defaults.Algorithm.ShiftLength; + } + + span[written++] = (byte)(rem + Defaults.Algorithm.QuestionMark); + + writer.Advance(written); + } } diff --git a/src/PolylineAlgorithm/PublicAPI.Shipped.txt b/src/PolylineAlgorithm/PublicAPI.Shipped.txt index 91b0e1a4..b2e4c242 100644 --- a/src/PolylineAlgorithm/PublicAPI.Shipped.txt +++ b/src/PolylineAlgorithm/PublicAPI.Shipped.txt @@ -1 +1,79 @@ -#nullable enable \ No newline at end of file +#nullable enable +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.AbstractPolylineDecoder() -> void +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.AbstractPolylineDecoder(PolylineAlgorithm.PolylineEncodingOptions! options) -> void +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.Decode(TPolyline polyline) -> System.Collections.Generic.IEnumerable! +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.Options.get -> PolylineAlgorithm.PolylineEncodingOptions! +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.AbstractPolylineEncoder() -> void +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.AbstractPolylineEncoder(PolylineAlgorithm.PolylineEncodingOptions! options) -> void +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.Encode(System.Collections.Generic.IEnumerable! coordinates) -> TPolyline +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.Options.get -> PolylineAlgorithm.PolylineEncodingOptions! +PolylineAlgorithm.Abstraction.IPolylineDecoder +PolylineAlgorithm.Abstraction.IPolylineDecoder.Decode(TPolyline polyline) -> System.Collections.Generic.IEnumerable! +PolylineAlgorithm.Abstraction.IPolylineEncoder +PolylineAlgorithm.Abstraction.IPolylineEncoder.Encode(System.Collections.Generic.IEnumerable! coordinates) -> TPolyline +PolylineAlgorithm.Coordinate +PolylineAlgorithm.Coordinate.Coordinate() -> void +PolylineAlgorithm.Coordinate.Coordinate(double latitude, double longitude) -> void +PolylineAlgorithm.Coordinate.Equals(PolylineAlgorithm.Coordinate other) -> bool +PolylineAlgorithm.Coordinate.IsDefault() -> bool +PolylineAlgorithm.Coordinate.Latitude.get -> double +PolylineAlgorithm.Coordinate.Longitude.get -> double +PolylineAlgorithm.CoordinateValueType +PolylineAlgorithm.CoordinateValueType.Latitude = 1 -> PolylineAlgorithm.CoordinateValueType +PolylineAlgorithm.CoordinateValueType.Longitude = 2 -> PolylineAlgorithm.CoordinateValueType +PolylineAlgorithm.CoordinateValueType.None = 0 -> PolylineAlgorithm.CoordinateValueType +PolylineAlgorithm.Extensions.PolylineDecoderExtensions +PolylineAlgorithm.Extensions.PolylineEncoderExtensions +PolylineAlgorithm.InvalidPolylineException +PolylineAlgorithm.Polyline +PolylineAlgorithm.Polyline.CopyTo(char[]! destination) -> void +PolylineAlgorithm.Polyline.Equals(PolylineAlgorithm.Polyline other) -> bool +PolylineAlgorithm.Polyline.IsEmpty.get -> bool +PolylineAlgorithm.Polyline.Length.get -> long +PolylineAlgorithm.Polyline.Polyline() -> void +PolylineAlgorithm.PolylineDecoder +PolylineAlgorithm.PolylineDecoder.PolylineDecoder() -> void +PolylineAlgorithm.PolylineDecoder.PolylineDecoder(PolylineAlgorithm.PolylineEncodingOptions! options) -> void +PolylineAlgorithm.PolylineEncoder +PolylineAlgorithm.PolylineEncoder.PolylineEncoder() -> void +PolylineAlgorithm.PolylineEncoder.PolylineEncoder(PolylineAlgorithm.PolylineEncodingOptions! options) -> void +PolylineAlgorithm.PolylineEncoding +PolylineAlgorithm.PolylineEncodingOptions +PolylineAlgorithm.PolylineEncodingOptions.LoggerFactory.get -> Microsoft.Extensions.Logging.ILoggerFactory! +PolylineAlgorithm.PolylineEncodingOptions.MaxBufferSize.get -> int +PolylineAlgorithm.PolylineEncodingOptions.PolylineEncodingOptions() -> void +PolylineAlgorithm.PolylineEncodingOptionsBuilder +PolylineAlgorithm.PolylineEncodingOptionsBuilder.Build() -> PolylineAlgorithm.PolylineEncodingOptions! +PolylineAlgorithm.PolylineEncodingOptionsBuilder.WithLoggerFactory(Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> PolylineAlgorithm.PolylineEncodingOptionsBuilder! +PolylineAlgorithm.PolylineEncodingOptionsBuilder.WithMaxBufferSize(int bufferSize) -> PolylineAlgorithm.PolylineEncodingOptionsBuilder! +abstract PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.CreateCoordinate(double latitude, double longitude) -> TCoordinate +abstract PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.GetReadOnlyMemory(TPolyline polyline) -> System.ReadOnlyMemory +abstract PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.CreatePolyline(System.ReadOnlyMemory polyline) -> TPolyline +abstract PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.GetLatitude(TCoordinate current) -> double +abstract PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.GetLongitude(TCoordinate current) -> double +override PolylineAlgorithm.Coordinate.Equals(object? obj) -> bool +override PolylineAlgorithm.Coordinate.GetHashCode() -> int +override PolylineAlgorithm.Coordinate.ToString() -> string! +override PolylineAlgorithm.Polyline.Equals(object! obj) -> bool +override PolylineAlgorithm.Polyline.GetHashCode() -> int +override PolylineAlgorithm.Polyline.ToString() -> string! +static PolylineAlgorithm.Coordinate.operator !=(PolylineAlgorithm.Coordinate left, PolylineAlgorithm.Coordinate right) -> bool +static PolylineAlgorithm.Coordinate.operator ==(PolylineAlgorithm.Coordinate left, PolylineAlgorithm.Coordinate right) -> bool +static PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode(this PolylineAlgorithm.Abstraction.IPolylineDecoder! decoder, System.ReadOnlyMemory polyline) -> System.Collections.Generic.IEnumerable! +static PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode(this PolylineAlgorithm.Abstraction.IPolylineDecoder! decoder, char[]! polyline) -> System.Collections.Generic.IEnumerable! +static PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode(this PolylineAlgorithm.Abstraction.IPolylineDecoder! decoder, string! polyline) -> System.Collections.Generic.IEnumerable! +static PolylineAlgorithm.Extensions.PolylineEncoderExtensions.Encode(this PolylineAlgorithm.Abstraction.IPolylineEncoder! encoder, PolylineAlgorithm.Coordinate[]! coordinates) -> PolylineAlgorithm.Polyline +static PolylineAlgorithm.Extensions.PolylineEncoderExtensions.Encode(this PolylineAlgorithm.Abstraction.IPolylineEncoder! encoder, System.Collections.Generic.ICollection! coordinates) -> PolylineAlgorithm.Polyline +static PolylineAlgorithm.Polyline.FromCharArray(char[]! polyline) -> PolylineAlgorithm.Polyline +static PolylineAlgorithm.Polyline.FromMemory(System.ReadOnlyMemory polyline) -> PolylineAlgorithm.Polyline +static PolylineAlgorithm.Polyline.FromString(string! polyline) -> PolylineAlgorithm.Polyline +static PolylineAlgorithm.Polyline.operator !=(PolylineAlgorithm.Polyline left, PolylineAlgorithm.Polyline right) -> bool +static PolylineAlgorithm.Polyline.operator ==(PolylineAlgorithm.Polyline left, PolylineAlgorithm.Polyline right) -> bool +static PolylineAlgorithm.PolylineEncoding.Denormalize(int value, PolylineAlgorithm.CoordinateValueType type) -> double +static PolylineAlgorithm.PolylineEncoding.GetCharCount(int variance) -> int +static PolylineAlgorithm.PolylineEncoding.Normalize(double value, PolylineAlgorithm.CoordinateValueType type) -> int +static PolylineAlgorithm.PolylineEncoding.TryReadValue(ref int variance, ref System.ReadOnlyMemory buffer, ref int position) -> bool +static PolylineAlgorithm.PolylineEncoding.TryWriteValue(int variance, ref System.Span buffer, ref int position) -> bool +static PolylineAlgorithm.PolylineEncodingOptionsBuilder.Create() -> PolylineAlgorithm.PolylineEncodingOptionsBuilder! diff --git a/src/PolylineAlgorithm/PublicAPI.Unshipped.txt b/src/PolylineAlgorithm/PublicAPI.Unshipped.txt index 91b0e1a4..40df2fb5 100644 --- a/src/PolylineAlgorithm/PublicAPI.Unshipped.txt +++ b/src/PolylineAlgorithm/PublicAPI.Unshipped.txt @@ -1 +1,19 @@ -#nullable enable \ No newline at end of file +#nullable enable +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.DecodeAsync(System.IO.Pipelines.PipeReader! reader, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.DecodeAsync(TPolyline polyline, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.EncodeAsync(System.Collections.Generic.IAsyncEnumerable! coordinates, System.IO.Pipelines.PipeWriter! writer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.EncodeAsync(System.Collections.Generic.IAsyncEnumerable! coordinates, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +PolylineAlgorithm.Abstraction.IAsyncPolylineDecoder +PolylineAlgorithm.Abstraction.IAsyncPolylineDecoder.DecodeAsync(TPolyline polyline, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +PolylineAlgorithm.Abstraction.IAsyncPolylineEncoder +PolylineAlgorithm.Abstraction.IAsyncPolylineEncoder.EncodeAsync(System.Collections.Generic.IAsyncEnumerable! coordinates, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +PolylineAlgorithm.Abstraction.IPolylinePipeDecoder +PolylineAlgorithm.Abstraction.IPolylinePipeDecoder.DecodeAsync(System.IO.Pipelines.PipeReader! reader, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +PolylineAlgorithm.Abstraction.IPolylinePipeEncoder +PolylineAlgorithm.Abstraction.IPolylinePipeEncoder.EncodeAsync(System.Collections.Generic.IAsyncEnumerable! coordinates, System.IO.Pipelines.PipeWriter! writer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +PolylineAlgorithm.Extensions.AsyncPolylineDecoderExtensions +static PolylineAlgorithm.Extensions.AsyncPolylineDecoderExtensions.DecodeAsync(this PolylineAlgorithm.Abstraction.IAsyncPolylineDecoder! decoder, System.ReadOnlyMemory polyline, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +static PolylineAlgorithm.Extensions.AsyncPolylineDecoderExtensions.DecodeAsync(this PolylineAlgorithm.Abstraction.IAsyncPolylineDecoder! decoder, char[]! polyline, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +static PolylineAlgorithm.Extensions.AsyncPolylineDecoderExtensions.DecodeAsync(this PolylineAlgorithm.Abstraction.IAsyncPolylineDecoder! decoder, string! polyline, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +PolylineAlgorithm.Extensions.AsyncPolylineEncoderExtensions +static PolylineAlgorithm.Extensions.AsyncPolylineEncoderExtensions.EncodeAsync(this PolylineAlgorithm.Abstraction.IAsyncPolylineEncoder! encoder, System.Collections.Generic.IEnumerable! coordinates, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask diff --git a/tests/PolylineAlgorithm.Tests/AsyncPolylineDecoderTest.cs b/tests/PolylineAlgorithm.Tests/AsyncPolylineDecoderTest.cs new file mode 100644 index 00000000..f0b114de --- /dev/null +++ b/tests/PolylineAlgorithm.Tests/AsyncPolylineDecoderTest.cs @@ -0,0 +1,163 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.Tests; + +using PolylineAlgorithm; +using PolylineAlgorithm.Abstraction; +using PolylineAlgorithm.Extensions; +using PolylineAlgorithm.Utility; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Defines tests for as implemented by +/// , and for . +/// +[TestClass] +public class AsyncPolylineDecoderTest { + private static readonly PolylineDecoder _decoder = new(); + + public static IEnumerable CoordinateCount => [[1], [10], [100], [1_000]]; + + // ── DecodeAsync(Polyline, CancellationToken) ────────────────────────────── + + [TestMethod] + public async Task DecodeAsync_Polyline_EmptyPolyline_Throws_ArgumentException() { + // Arrange & Act + Task Execute() => _decoder.DecodeAsync(new Polyline(), CancellationToken.None).ToListAsync().AsTask(); + + // Assert + await Assert.ThrowsExactlyAsync(Execute); + } + + [TestMethod] + public async Task DecodeAsync_Polyline_Cancelled_Throws_OperationCanceledException() { + // Arrange + string polyline = StaticValueProvider.Valid.GetPolyline(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act + Task Execute() => _decoder.DecodeAsync(Polyline.FromString(polyline), cts.Token).ToListAsync().AsTask(); + + // Assert + await Assert.ThrowsExactlyAsync(Execute); + } + + [TestMethod] + [DynamicData(nameof(CoordinateCount))] + public async Task DecodeAsync_Polyline_Random_ValidInput_Ok(int count) { + // Arrange + IEnumerable expected = RandomValueProvider.GetCoordinates(count).Select(c => new Coordinate(c.Latitude, c.Longitude)); + Polyline value = Polyline.FromString(RandomValueProvider.GetPolyline(count)); + + // Act + var result = await _decoder.DecodeAsync(value, CancellationToken.None).ToListAsync(); + + // Assert + CollectionAssert.AreEqual(expected.ToArray(), result.ToArray()); + } + + [TestMethod] + public async Task DecodeAsync_Polyline_Static_ValidInput_Ok() { + // Arrange + IEnumerable expected = StaticValueProvider.Valid.GetCoordinates().Select(c => new Coordinate(c.Latitude, c.Longitude)); + Polyline value = Polyline.FromString(StaticValueProvider.Valid.GetPolyline()); + + // Act + var result = await _decoder.DecodeAsync(value, CancellationToken.None).ToListAsync(); + + // Assert + CollectionAssert.AreEqual(expected.ToArray(), result.ToArray()); + } + + // ── DecodeAsync extension — string overload ─────────────────────────────── + + [TestMethod] + public async Task DecodeAsync_String_NullDecoder_Throws_ArgumentNullException() { + // Arrange + PolylineDecoder decoder = null!; + + // Act + Task Execute() => AsyncPolylineDecoderExtensions.DecodeAsync(decoder, string.Empty, CancellationToken.None).ToListAsync().AsTask(); + + // Assert + var exception = await Assert.ThrowsExactlyAsync(Execute); + Assert.AreEqual("decoder", exception.ParamName); + } + + [TestMethod] + public async Task DecodeAsync_String_Static_ValidInput_Ok() { + // Arrange + IEnumerable expected = StaticValueProvider.Valid.GetCoordinates().Select(c => new Coordinate(c.Latitude, c.Longitude)); + string polyline = StaticValueProvider.Valid.GetPolyline(); + + // Act + var result = await _decoder.DecodeAsync(polyline, CancellationToken.None).ToListAsync(); + + // Assert + CollectionAssert.AreEqual(expected.ToArray(), result.ToArray()); + } + + // ── DecodeAsync extension — char[] overload ─────────────────────────────── + + [TestMethod] + public async Task DecodeAsync_CharArray_NullDecoder_Throws_ArgumentNullException() { + // Arrange + PolylineDecoder decoder = null!; + + // Act + Task Execute() => AsyncPolylineDecoderExtensions.DecodeAsync(decoder, Array.Empty(), CancellationToken.None).ToListAsync().AsTask(); + + // Assert + var exception = await Assert.ThrowsExactlyAsync(Execute); + Assert.AreEqual("decoder", exception.ParamName); + } + + [TestMethod] + public async Task DecodeAsync_CharArray_Static_ValidInput_Ok() { + // Arrange + IEnumerable expected = StaticValueProvider.Valid.GetCoordinates().Select(c => new Coordinate(c.Latitude, c.Longitude)); + char[] polyline = StaticValueProvider.Valid.GetPolyline().ToCharArray(); + + // Act + var result = await _decoder.DecodeAsync(polyline, CancellationToken.None).ToListAsync(); + + // Assert + CollectionAssert.AreEqual(expected.ToArray(), result.ToArray()); + } + + // ── DecodeAsync extension — ReadOnlyMemory overload ──────────────── + + [TestMethod] + public async Task DecodeAsync_Memory_NullDecoder_Throws_ArgumentNullException() { + // Arrange + PolylineDecoder decoder = null!; + + // Act + Task Execute() => AsyncPolylineDecoderExtensions.DecodeAsync(decoder, ReadOnlyMemory.Empty, CancellationToken.None).ToListAsync().AsTask(); + + // Assert + var exception = await Assert.ThrowsExactlyAsync(Execute); + Assert.AreEqual("decoder", exception.ParamName); + } + + [TestMethod] + public async Task DecodeAsync_Memory_Static_ValidInput_Ok() { + // Arrange + IEnumerable expected = StaticValueProvider.Valid.GetCoordinates().Select(c => new Coordinate(c.Latitude, c.Longitude)); + ReadOnlyMemory polyline = StaticValueProvider.Valid.GetPolyline().AsMemory(); + + // Act + var result = await _decoder.DecodeAsync(polyline, CancellationToken.None).ToListAsync(); + + // Assert + CollectionAssert.AreEqual(expected.ToArray(), result.ToArray()); + } +} diff --git a/tests/PolylineAlgorithm.Tests/AsyncPolylineEncoderTest.cs b/tests/PolylineAlgorithm.Tests/AsyncPolylineEncoderTest.cs new file mode 100644 index 00000000..6722c9f8 --- /dev/null +++ b/tests/PolylineAlgorithm.Tests/AsyncPolylineEncoderTest.cs @@ -0,0 +1,150 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.Tests; + +using PolylineAlgorithm; +using PolylineAlgorithm.Abstraction; +using PolylineAlgorithm.Extensions; +using PolylineAlgorithm.Utility; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Defines tests for as implemented by +/// , and for . +/// +[TestClass] +public class AsyncPolylineEncoderTest { + private static readonly PolylineEncoder _encoder = new(); + + public static IEnumerable CoordinateCount => [[1], [10], [100], [1_000]]; + + // ── EncodeAsync(IAsyncEnumerable, CancellationToken) ───────────────────── + + [TestMethod] + public async Task EncodeAsync_NullCoordinates_Throws_ArgumentNullException() { + // Arrange + IAsyncEnumerable coordinates = null!; + + // Act + Task Execute() => _encoder.EncodeAsync(coordinates, CancellationToken.None).AsTask(); + + // Assert + var exception = await Assert.ThrowsExactlyAsync(Execute); + Assert.AreEqual("coordinates", exception.ParamName); + } + + [TestMethod] + public async Task EncodeAsync_EmptyCoordinates_Throws_ArgumentException() { + // Arrange + async IAsyncEnumerable Empty() { await Task.CompletedTask; yield break; } + + // Act + Task Execute() => _encoder.EncodeAsync(Empty(), CancellationToken.None).AsTask(); + + // Assert + await Assert.ThrowsExactlyAsync(Execute); + } + + [TestMethod] + [DynamicData(nameof(CoordinateCount))] + public async Task EncodeAsync_Random_ValidInput_Ok(int count) { + // Arrange + IEnumerable coordinates = RandomValueProvider.GetCoordinates(count).Select(c => new Coordinate(c.Latitude, c.Longitude)); + Polyline expected = Polyline.FromString(RandomValueProvider.GetPolyline(count)); + + // Act + Polyline result = await _encoder.EncodeAsync(ToAsyncEnumerable(coordinates), CancellationToken.None); + + // Assert + Assert.IsTrue(expected.Equals(result)); + } + + [TestMethod] + public async Task EncodeAsync_Static_ValidInput_Ok() { + // Arrange + IEnumerable coordinates = StaticValueProvider.Valid.GetCoordinates().Select(c => new Coordinate(c.Latitude, c.Longitude)); + Polyline expected = Polyline.FromString(StaticValueProvider.Valid.GetPolyline()); + + // Act + Polyline result = await _encoder.EncodeAsync(ToAsyncEnumerable(coordinates), CancellationToken.None); + + // Assert + Assert.IsTrue(expected.Equals(result)); + } + + // ── EncodeAsync extension — IEnumerable overload ───────────── + + [TestMethod] + public async Task EncodeAsync_Extension_NullEncoder_Throws_ArgumentNullException() { + // Arrange + PolylineEncoder encoder = null!; + + // Act + Task Execute() => AsyncPolylineEncoderExtensions.EncodeAsync(encoder, Array.Empty(), CancellationToken.None).AsTask(); + + // Assert + var exception = await Assert.ThrowsExactlyAsync(Execute); + Assert.AreEqual("encoder", exception.ParamName); + } + + [TestMethod] + public async Task EncodeAsync_Extension_NullCoordinates_Throws_ArgumentNullException() { + // Arrange + IEnumerable coordinates = null!; + + // Act + Task Execute() => _encoder.EncodeAsync(coordinates, CancellationToken.None).AsTask(); + + // Assert + var exception = await Assert.ThrowsExactlyAsync(Execute); + Assert.AreEqual("coordinates", exception.ParamName); + } + + [TestMethod] + public async Task EncodeAsync_Extension_Static_ValidInput_Ok() { + // Arrange + IEnumerable coordinates = StaticValueProvider.Valid.GetCoordinates().Select(c => new Coordinate(c.Latitude, c.Longitude)); + Polyline expected = Polyline.FromString(StaticValueProvider.Valid.GetPolyline()); + + // Act + Polyline result = await _encoder.EncodeAsync(coordinates, CancellationToken.None); + + // Assert + Assert.IsTrue(expected.Equals(result)); + } + + // ── Round-trip ──────────────────────────────────────────────────────────── + + [TestMethod] + public async Task EncodeDecodeAsync_RoundTrip_Ok() { + // Arrange + var decoder = new PolylineDecoder(); + var coordinates = new List { new(10, 20), new(-10, -20), new(0, 0) }; + + // Act + Polyline polyline = await _encoder.EncodeAsync(ToAsyncEnumerable(coordinates), CancellationToken.None); + var decoded = await decoder.DecodeAsync(polyline, CancellationToken.None).ToListAsync(); + + // Assert + Assert.AreEqual(coordinates.Count, decoded.Count); + for (int i = 0; i < coordinates.Count; i++) { + Assert.AreEqual(coordinates[i].Latitude, decoded[i].Latitude); + Assert.AreEqual(coordinates[i].Longitude, decoded[i].Longitude); + } + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source) { + foreach (var item in source) { + yield return item; + } + + await Task.CompletedTask; + } +} diff --git a/tests/PolylineAlgorithm.Tests/PolylinePipeDecoderTest.cs b/tests/PolylineAlgorithm.Tests/PolylinePipeDecoderTest.cs new file mode 100644 index 00000000..873a9e32 --- /dev/null +++ b/tests/PolylineAlgorithm.Tests/PolylinePipeDecoderTest.cs @@ -0,0 +1,131 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.Tests; + +using PolylineAlgorithm; +using PolylineAlgorithm.Abstraction; +using PolylineAlgorithm.Utility; +using System; +using System.Collections.Generic; +using System.IO.Pipelines; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Defines tests for as implemented by +/// , covering the zero-allocation -based decode path. +/// +[TestClass] +public class PolylinePipeDecoderTest { + private static readonly PolylineDecoder _decoder = new(); + + public static IEnumerable CoordinateCount => [[1], [10], [100], [1_000]]; + + // ── Null / empty argument checks ───────────────────────────────────────── + + [TestMethod] + public async Task DecodeAsync_NullReader_Throws_ArgumentNullException() { + // Arrange + PipeReader reader = null!; + + // Act + Task Execute() => _decoder.DecodeAsync(reader, CancellationToken.None).ToListAsync().AsTask(); + + // Assert + var exception = await Assert.ThrowsExactlyAsync(Execute); + Assert.AreEqual("reader", exception.ParamName); + } + + [TestMethod] + public async Task DecodeAsync_EmptyPipe_Throws_ArgumentException() { + // Arrange + PipeReader reader = PipeReader.Create(new System.IO.MemoryStream([])); + + // Act + Task Execute() => _decoder.DecodeAsync(reader, CancellationToken.None).ToListAsync().AsTask(); + + // Assert + await Assert.ThrowsExactlyAsync(Execute); + } + + [TestMethod] + public async Task DecodeAsync_Cancelled_Throws_OperationCanceledException() { + // Arrange + byte[] bytes = Encoding.ASCII.GetBytes(StaticValueProvider.Valid.GetPolyline()); + PipeReader reader = PipeReader.Create(new System.IO.MemoryStream(bytes)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act — PipeReader.ReadAsync with a cancelled token throws TaskCanceledException + // (a subtype of OperationCanceledException) + Task Execute() => _decoder.DecodeAsync(reader, cts.Token).ToListAsync().AsTask(); + + // Assert + var exception = await Assert.ThrowsExactlyAsync(Execute); + } + + // ── Correctness ─────────────────────────────────────────────────────────── + + [TestMethod] + public async Task DecodeAsync_Static_ValidInput_Ok() { + // Arrange + IEnumerable expected = StaticValueProvider.Valid.GetCoordinates() + .Select(c => new Coordinate(c.Latitude, c.Longitude)); + byte[] bytes = Encoding.ASCII.GetBytes(StaticValueProvider.Valid.GetPolyline()); + PipeReader reader = PipeReader.Create(new System.IO.MemoryStream(bytes)); + + // Act + var result = await _decoder.DecodeAsync(reader, CancellationToken.None).ToListAsync(); + + // Assert + CollectionAssert.AreEqual(expected.ToArray(), result.ToArray()); + } + + [TestMethod] + [DynamicData(nameof(CoordinateCount))] + public async Task DecodeAsync_Random_ValidInput_Ok(int count) { + // Arrange + IEnumerable expected = RandomValueProvider.GetCoordinates(count) + .Select(c => new Coordinate(c.Latitude, c.Longitude)); + byte[] bytes = Encoding.ASCII.GetBytes(RandomValueProvider.GetPolyline(count)); + PipeReader reader = PipeReader.Create(new System.IO.MemoryStream(bytes)); + + // Act + var result = await _decoder.DecodeAsync(reader, CancellationToken.None).ToListAsync(); + + // Assert + CollectionAssert.AreEqual(expected.ToArray(), result.ToArray()); + } + + [TestMethod] + public async Task DecodeAsync_SmallBufferChunks_ValidInput_Ok() { + // Arrange — deliver bytes 2 at a time to exercise multi-read code paths + IEnumerable expected = StaticValueProvider.Valid.GetCoordinates() + .Select(c => new Coordinate(c.Latitude, c.Longitude)); + byte[] bytes = Encoding.ASCII.GetBytes(StaticValueProvider.Valid.GetPolyline()); + + var pipe = new Pipe(new PipeOptions(minimumSegmentSize: 2)); + + // Write to pipe in 2-byte chunks + _ = Task.Run(async () => { + for (int i = 0; i < bytes.Length; i += 2) { + int chunkSize = Math.Min(2, bytes.Length - i); + await pipe.Writer.WriteAsync(new Memory(bytes, i, chunkSize)); + await pipe.Writer.FlushAsync(); + } + + await pipe.Writer.CompleteAsync(); + }); + + // Act + var result = await _decoder.DecodeAsync(pipe.Reader, CancellationToken.None).ToListAsync(); + + // Assert + CollectionAssert.AreEqual(expected.ToArray(), result.ToArray()); + } +} diff --git a/tests/PolylineAlgorithm.Tests/PolylinePipeEncoderTest.cs b/tests/PolylineAlgorithm.Tests/PolylinePipeEncoderTest.cs new file mode 100644 index 00000000..fcd4dbf5 --- /dev/null +++ b/tests/PolylineAlgorithm.Tests/PolylinePipeEncoderTest.cs @@ -0,0 +1,139 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.Tests; + +using PolylineAlgorithm; +using PolylineAlgorithm.Abstraction; +using PolylineAlgorithm.Utility; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO.Pipelines; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Defines tests for as implemented by +/// , covering the zero-allocation -based encode path. +/// +[TestClass] +public class PolylinePipeEncoderTest { + private static readonly PolylineEncoder _encoder = new(); + + public static IEnumerable CoordinateCount => [[1], [10], [100], [1_000]]; + + // ── Null / empty argument checks ───────────────────────────────────────── + + [TestMethod] + public async Task EncodeAsync_NullCoordinates_Throws_ArgumentNullException() { + // Arrange + IAsyncEnumerable coordinates = null!; + PipeWriter writer = new Pipe().Writer; + + // Act + Task Execute() => _encoder.EncodeAsync(coordinates, writer, CancellationToken.None).AsTask(); + + // Assert + var exception = await Assert.ThrowsExactlyAsync(Execute); + Assert.AreEqual("coordinates", exception.ParamName); + } + + [TestMethod] + public async Task EncodeAsync_NullWriter_Throws_ArgumentNullException() { + // Arrange + PipeWriter writer = null!; + + // Act + Task Execute() => _encoder.EncodeAsync(ToAsyncEnumerable([]), writer, CancellationToken.None).AsTask(); + + // Assert + var exception = await Assert.ThrowsExactlyAsync(Execute); + Assert.AreEqual("writer", exception.ParamName); + } + + // ── Correctness ─────────────────────────────────────────────────────────── + + [TestMethod] + public async Task EncodeAsync_Static_ValidInput_WritesExpectedBytes() { + // Arrange + IEnumerable coordinates = StaticValueProvider.Valid.GetCoordinates() + .Select(c => new Coordinate(c.Latitude, c.Longitude)); + string expected = StaticValueProvider.Valid.GetPolyline(); + + var pipe = new Pipe(); + + // Act + await _encoder.EncodeAsync(ToAsyncEnumerable(coordinates), pipe.Writer, CancellationToken.None); + await pipe.Writer.CompleteAsync(); + + ReadResult readResult = await pipe.Reader.ReadAsync(CancellationToken.None); + string actual = Encoding.ASCII.GetString(readResult.Buffer.IsSingleSegment + ? readResult.Buffer.First.Span + : readResult.Buffer.ToArray()); + pipe.Reader.AdvanceTo(readResult.Buffer.End); + + // Assert + Assert.AreEqual(expected, actual); + } + + [TestMethod] + [DynamicData(nameof(CoordinateCount))] + public async Task EncodeAsync_Random_ValidInput_WritesExpectedBytes(int count) { + // Arrange + IEnumerable coordinates = RandomValueProvider.GetCoordinates(count) + .Select(c => new Coordinate(c.Latitude, c.Longitude)); + string expected = RandomValueProvider.GetPolyline(count); + + var pipe = new Pipe(); + + // Act + await _encoder.EncodeAsync(ToAsyncEnumerable(coordinates), pipe.Writer, CancellationToken.None); + await pipe.Writer.CompleteAsync(); + + ReadResult readResult = await pipe.Reader.ReadAsync(CancellationToken.None); + string actual = Encoding.ASCII.GetString(readResult.Buffer.IsSingleSegment + ? readResult.Buffer.First.Span + : readResult.Buffer.ToArray()); + pipe.Reader.AdvanceTo(readResult.Buffer.End); + + // Assert + Assert.AreEqual(expected, actual); + } + + // ── Round-trip via pipes ────────────────────────────────────────────────── + + [TestMethod] + public async Task EncodeDecode_RoundTrip_Via_Pipes_Ok() { + // Arrange + var decoder = new PolylineDecoder(); + var coordinates = new List { new(10, 20), new(-10, -20), new(0, 0) }; + var pipe = new Pipe(); + + // Act — encode to pipe + await _encoder.EncodeAsync(ToAsyncEnumerable(coordinates), pipe.Writer, CancellationToken.None); + await pipe.Writer.CompleteAsync(); + + // Act — decode from pipe + var decoded = await decoder.DecodeAsync(pipe.Reader, CancellationToken.None).ToListAsync(); + + // Assert + Assert.AreEqual(coordinates.Count, decoded.Count); + for (int i = 0; i < coordinates.Count; i++) { + Assert.AreEqual(coordinates[i].Latitude, decoded[i].Latitude); + Assert.AreEqual(coordinates[i].Longitude, decoded[i].Longitude); + } + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source) { + foreach (var item in source) { + yield return item; + } + + await Task.CompletedTask; + } +} From a4898b689ed19b65e6bc936340c266c9c3ccb06e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:24:48 +0000 Subject: [PATCH 2/2] refactor: remove unnecessary await Task.CompletedTask from async iterators Co-authored-by: petesramek <2333452+petesramek@users.noreply.github.com> Agent-Logs-Url: https://github.com/petesramek/polyline-algorithm-csharp/sessions/8bac762b-8608-43c2-9c46-e9485c2acf63 --- src/PolylineAlgorithm/Abstraction/AbstractPolylineDecoder.cs | 2 -- .../Extensions/AsyncPolylineEncoderExtensions.cs | 2 -- tests/PolylineAlgorithm.Tests/AsyncPolylineEncoderTest.cs | 2 -- tests/PolylineAlgorithm.Tests/PolylinePipeEncoderTest.cs | 2 -- 4 files changed, 8 deletions(-) diff --git a/src/PolylineAlgorithm/Abstraction/AbstractPolylineDecoder.cs b/src/PolylineAlgorithm/Abstraction/AbstractPolylineDecoder.cs index d8c66cf0..60843efe 100644 --- a/src/PolylineAlgorithm/Abstraction/AbstractPolylineDecoder.cs +++ b/src/PolylineAlgorithm/Abstraction/AbstractPolylineDecoder.cs @@ -167,8 +167,6 @@ public async IAsyncEnumerable DecodeAsync( cancellationToken.ThrowIfCancellationRequested(); yield return coordinate; } - - await Task.CompletedTask.ConfigureAwait(false); } /// diff --git a/src/PolylineAlgorithm/Extensions/AsyncPolylineEncoderExtensions.cs b/src/PolylineAlgorithm/Extensions/AsyncPolylineEncoderExtensions.cs index 7b0da2be..a406236a 100644 --- a/src/PolylineAlgorithm/Extensions/AsyncPolylineEncoderExtensions.cs +++ b/src/PolylineAlgorithm/Extensions/AsyncPolylineEncoderExtensions.cs @@ -59,7 +59,5 @@ private static async IAsyncEnumerable ToAsyncEnumerable( cancellationToken.ThrowIfCancellationRequested(); yield return item; } - - await System.Threading.Tasks.Task.CompletedTask.ConfigureAwait(false); } } diff --git a/tests/PolylineAlgorithm.Tests/AsyncPolylineEncoderTest.cs b/tests/PolylineAlgorithm.Tests/AsyncPolylineEncoderTest.cs index 6722c9f8..9160688e 100644 --- a/tests/PolylineAlgorithm.Tests/AsyncPolylineEncoderTest.cs +++ b/tests/PolylineAlgorithm.Tests/AsyncPolylineEncoderTest.cs @@ -144,7 +144,5 @@ private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable< foreach (var item in source) { yield return item; } - - await Task.CompletedTask; } } diff --git a/tests/PolylineAlgorithm.Tests/PolylinePipeEncoderTest.cs b/tests/PolylineAlgorithm.Tests/PolylinePipeEncoderTest.cs index fcd4dbf5..d5f79174 100644 --- a/tests/PolylineAlgorithm.Tests/PolylinePipeEncoderTest.cs +++ b/tests/PolylineAlgorithm.Tests/PolylinePipeEncoderTest.cs @@ -133,7 +133,5 @@ private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable< foreach (var item in source) { yield return item; } - - await Task.CompletedTask; } }