diff --git a/src/ToonFormat/ToonDecoder.cs b/src/ToonFormat/ToonDecoder.cs index 41a6e29..dc1551f 100644 --- a/src/ToonFormat/ToonDecoder.cs +++ b/src/ToonFormat/ToonDecoder.cs @@ -4,6 +4,8 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; using ToonFormat; using ToonFormat.Internal.Decode; @@ -194,4 +196,124 @@ public static class ToonDecoder var text = reader.ReadToEnd(); return Decode(text, options ?? new ToonDecodeOptions()); } + + #region Async Methods + + /// + /// Asynchronously decodes a TOON-formatted string into a JsonNode with default options. + /// + /// The TOON-formatted string to decode. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the decoded JsonNode. + /// Thrown when toonString is null. + /// Thrown when the TOON format is invalid. + public static Task DecodeAsync(string toonString, CancellationToken cancellationToken = default) + { + return DecodeAsync(toonString, new ToonDecodeOptions(), cancellationToken); + } + + /// + /// Asynchronously decodes a TOON-formatted string into a JsonNode with custom options. + /// + /// The TOON-formatted string to decode. + /// Decoding options to customize parsing behavior. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the decoded JsonNode. + /// Thrown when toonString or options is null. + /// Thrown when the TOON format is invalid. + public static Task DecodeAsync(string toonString, ToonDecodeOptions? options, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = Decode(toonString, options); + return Task.FromResult(result); + } + + /// + /// Asynchronously decodes a TOON-formatted string into the specified type with default options. + /// + /// Target type to deserialize into. + /// The TOON-formatted string to decode. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the deserialized value. + public static Task DecodeAsync(string toonString, CancellationToken cancellationToken = default) + { + return DecodeAsync(toonString, new ToonDecodeOptions(), cancellationToken); + } + + /// + /// Asynchronously decodes a TOON-formatted string into the specified type with custom options. + /// + /// Target type to deserialize into. + /// The TOON-formatted string to decode. + /// Decoding options to customize parsing behavior. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the deserialized value. + public static Task DecodeAsync(string toonString, ToonDecodeOptions? options, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = Decode(toonString, options); + return Task.FromResult(result); + } + + /// + /// Asynchronously decodes TOON data from a stream (UTF-8) into a JsonNode with default options. + /// + /// The input stream to read from. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the decoded JsonNode. + public static Task DecodeAsync(Stream stream, CancellationToken cancellationToken = default) + { + return DecodeAsync(stream, new ToonDecodeOptions(), cancellationToken); + } + + /// + /// Asynchronously decodes TOON data from a stream (UTF-8) into a JsonNode with custom options. + /// + /// The input stream to read from. + /// Decoding options to customize parsing behavior. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the decoded JsonNode. + /// Thrown when stream is null. + public static async Task DecodeAsync(Stream stream, ToonDecodeOptions? options, CancellationToken cancellationToken = default) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); + var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + return Decode(text, options ?? new ToonDecodeOptions()); + } + + /// + /// Asynchronously decodes TOON data from a stream (UTF-8) into the specified type with default options. + /// + /// Target type to deserialize into. + /// The input stream to read from. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the deserialized value. + public static Task DecodeAsync(Stream stream, CancellationToken cancellationToken = default) + { + return DecodeAsync(stream, new ToonDecodeOptions(), cancellationToken); + } + + /// + /// Asynchronously decodes TOON data from a stream (UTF-8) into the specified type with custom options. + /// + /// Target type to deserialize into. + /// The input stream to read from. + /// Decoding options to customize parsing behavior. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the deserialized value. + /// Thrown when stream is null. + public static async Task DecodeAsync(Stream stream, ToonDecodeOptions? options, CancellationToken cancellationToken = default) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); + var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + return Decode(text, options ?? new ToonDecodeOptions()); + } + + #endregion } diff --git a/src/ToonFormat/ToonEncoder.cs b/src/ToonFormat/ToonEncoder.cs index 53b5eb3..1e9ac7b 100644 --- a/src/ToonFormat/ToonEncoder.cs +++ b/src/ToonFormat/ToonEncoder.cs @@ -3,6 +3,8 @@ using System.ComponentModel; using System.IO; using System.Text; +using System.Threading; +using System.Threading.Tasks; using ToonFormat; using ToonFormat.Internal.Encode; @@ -192,4 +194,97 @@ public static void EncodeToStream(T data, Stream destination, ToonEncodeOptio var bytes = EncodeToBytes(data, options); destination.Write(bytes, 0, bytes.Length); } + + #region Async Methods + + /// + /// Asynchronously encodes the specified value into TOON format with default options. + /// + /// Type of the value to encode. + /// The value to encode. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the TOON-formatted string. + public static Task EncodeAsync(T data, CancellationToken cancellationToken = default) + { + return EncodeAsync(data, new ToonEncodeOptions(), cancellationToken); + } + + /// + /// Asynchronously encodes the specified value into TOON format with custom options. + /// + /// Type of the value to encode. + /// The value to encode. + /// Encoding options to customize the output format. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the TOON-formatted string. + /// Thrown when options is null. + public static Task EncodeAsync(T data, ToonEncodeOptions? options, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = Encode(data, options); + return Task.FromResult(result); + } + + /// + /// Asynchronously encodes the specified value into UTF-8 bytes with default options. + /// + /// Type of the value to encode. + /// The value to encode. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the UTF-8 encoded TOON bytes. + public static Task EncodeToBytesAsync(T data, CancellationToken cancellationToken = default) + { + return EncodeToBytesAsync(data, new ToonEncodeOptions(), cancellationToken); + } + + /// + /// Asynchronously encodes the specified value into UTF-8 bytes with custom options. + /// + /// Type of the value to encode. + /// The value to encode. + /// Encoding options to customize the output format. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the UTF-8 encoded TOON bytes. + /// Thrown when options is null. + public static Task EncodeToBytesAsync(T data, ToonEncodeOptions? options, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = EncodeToBytes(data, options); + return Task.FromResult(result); + } + + /// + /// Asynchronously encodes the specified value and writes UTF-8 bytes to the destination stream using default options. + /// + /// Type of the value to encode. + /// The value to encode. + /// The destination stream to write to. The stream is not disposed. + /// A token to cancel the operation. + /// A task that represents the asynchronous write operation. + public static Task EncodeToStreamAsync(T data, Stream destination, CancellationToken cancellationToken = default) + { + return EncodeToStreamAsync(data, destination, new ToonEncodeOptions(), cancellationToken); + } + + /// + /// Asynchronously encodes the specified value and writes UTF-8 bytes to the destination stream using custom options. + /// + /// Type of the value to encode. + /// The value to encode. + /// The destination stream to write to. The stream is not disposed. + /// Encoding options to customize the output format. + /// A token to cancel the operation. + /// A task that represents the asynchronous write operation. + /// Thrown when destination or options is null. + public static async Task EncodeToStreamAsync(T data, Stream destination, ToonEncodeOptions? options, CancellationToken cancellationToken = default) + { + if (destination == null) + throw new ArgumentNullException(nameof(destination)); + + cancellationToken.ThrowIfCancellationRequested(); + var bytes = EncodeToBytes(data, options); + await destination.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); + } + + #endregion } diff --git a/tests/ToonFormat.Tests/ToonAsyncTests.cs b/tests/ToonFormat.Tests/ToonAsyncTests.cs new file mode 100644 index 0000000..b6db96e --- /dev/null +++ b/tests/ToonFormat.Tests/ToonAsyncTests.cs @@ -0,0 +1,262 @@ +#nullable enable +using System; +using System.IO; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Toon.Format; +using Xunit; + +namespace ToonFormat.Tests; + +/// +/// Tests for async encoding and decoding methods. +/// +public class ToonAsyncTests +{ + #region EncodeAsync Tests + + [Fact] + public async Task EncodeAsync_WithSimpleObject_ReturnsValidToon() + { + // Arrange + var data = new { name = "Alice", age = 30 }; + + // Act + var result = await ToonEncoder.EncodeAsync(data); + + // Assert + Assert.NotNull(result); + Assert.Contains("name:", result); + Assert.Contains("Alice", result); + Assert.Contains("age:", result); + Assert.Contains("30", result); + } + + [Fact] + public async Task EncodeAsync_WithOptions_RespectsIndentation() + { + // Arrange + var data = new { outer = new { inner = "value" } }; + var options = new ToonEncodeOptions { Indent = 4 }; + + // Act + var result = await ToonEncoder.EncodeAsync(data, options); + + // Assert + Assert.NotNull(result); + Assert.Contains("outer:", result); + } + + [Fact] + public async Task EncodeAsync_WithCancellationToken_ThrowsWhenCancelled() + { + // Arrange + var data = new { name = "Test" }; + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync( + () => ToonEncoder.EncodeAsync(data, cts.Token)); + } + + [Fact] + public async Task EncodeToBytesAsync_WithSimpleObject_ReturnsUtf8Bytes() + { + // Arrange + var data = new { message = "Hello" }; + + // Act + var result = await ToonEncoder.EncodeToBytesAsync(data); + + // Assert + Assert.NotNull(result); + var text = Encoding.UTF8.GetString(result); + Assert.Contains("message:", text); + Assert.Contains("Hello", text); + } + + [Fact] + public async Task EncodeToStreamAsync_WritesToStream() + { + // Arrange + var data = new { id = 123 }; + using var stream = new MemoryStream(); + + // Act + await ToonEncoder.EncodeToStreamAsync(data, stream); + + // Assert + stream.Position = 0; + using var reader = new StreamReader(stream); + var result = await reader.ReadToEndAsync(); + Assert.Contains("id:", result); + Assert.Contains("123", result); + } + + [Fact] + public async Task EncodeToStreamAsync_WithNullStream_ThrowsArgumentNullException() + { + // Arrange + var data = new { name = "Test" }; + + // Act & Assert + await Assert.ThrowsAsync( + () => ToonEncoder.EncodeToStreamAsync(data, null!)); + } + + #endregion + + #region DecodeAsync Tests + + [Fact] + public async Task DecodeAsync_WithValidToon_ReturnsJsonNode() + { + // Arrange + var toon = "name: Alice\nage: 30"; + + // Act + var result = await ToonDecoder.DecodeAsync(toon); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + var obj = (JsonObject)result; + Assert.Equal("Alice", obj["name"]?.GetValue()); + Assert.Equal(30.0, obj["age"]?.GetValue()); + } + + [Fact] + public async Task DecodeAsync_Generic_DeserializesToType() + { + // Arrange + var toon = "name: Bob\nage: 25"; + + // Act + var result = await ToonDecoder.DecodeAsync(toon); + + // Assert + Assert.NotNull(result); + Assert.Equal("Bob", result.name); + Assert.Equal(25, result.age); + } + + [Fact] + public async Task DecodeAsync_WithCancellationToken_ThrowsWhenCancelled() + { + // Arrange + var toon = "name: Test"; + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync( + () => ToonDecoder.DecodeAsync(toon, cts.Token)); + } + + [Fact] + public async Task DecodeAsync_FromStream_ReturnsJsonNode() + { + // Arrange + var toon = "message: Hello World"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(toon)); + + // Act + var result = await ToonDecoder.DecodeAsync(stream); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + var obj = (JsonObject)result; + Assert.Equal("Hello World", obj["message"]?.GetValue()); + } + + [Fact] + public async Task DecodeAsync_Generic_FromStream_DeserializesToType() + { + // Arrange + var toon = "name: Charlie\nage: 35"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(toon)); + + // Act + var result = await ToonDecoder.DecodeAsync(stream); + + // Assert + Assert.NotNull(result); + Assert.Equal("Charlie", result.name); + Assert.Equal(35, result.age); + } + + [Fact] + public async Task DecodeAsync_FromStream_WithNullStream_ThrowsArgumentNullException() + { + // Act & Assert + await Assert.ThrowsAsync( + () => ToonDecoder.DecodeAsync((Stream)null!)); + } + + [Fact] + public async Task DecodeAsync_WithOptions_RespectsStrictMode() + { + // Arrange - array declares 5 items but only provides 3, strict mode should throw + var toon = "numbers[5]: 1, 2, 3"; + var options = new ToonDecodeOptions { Strict = true }; + + // Act & Assert + await Assert.ThrowsAsync( + () => ToonDecoder.DecodeAsync(toon, options)); + } + + #endregion + + #region Round-Trip Async Tests + + [Fact] + public async Task AsyncRoundTrip_PreservesData() + { + // Arrange + var original = new TestPerson { name = "Diana", age = 28 }; + + // Act + var encoded = await ToonEncoder.EncodeAsync(original); + var decoded = await ToonDecoder.DecodeAsync(encoded); + + // Assert + Assert.NotNull(decoded); + Assert.Equal(original.name, decoded.name); + Assert.Equal(original.age, decoded.age); + } + + [Fact] + public async Task AsyncStreamRoundTrip_PreservesData() + { + // Arrange + var original = new TestPerson { name = "Eve", age = 32 }; + using var stream = new MemoryStream(); + + // Act + await ToonEncoder.EncodeToStreamAsync(original, stream); + stream.Position = 0; + var decoded = await ToonDecoder.DecodeAsync(stream); + + // Assert + Assert.NotNull(decoded); + Assert.Equal(original.name, decoded.name); + Assert.Equal(original.age, decoded.age); + } + + #endregion + + #region Test Helpers + + private class TestPerson + { + public string? name { get; set; } + public int age { get; set; } + } + + #endregion +} +