diff --git a/README.md b/README.md index 5c44451..db93791 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,37 @@ [![.NET version](https://img.shields.io/badge/.NET-8.0%20%7C%209.0-512BD4)](https://dotnet.microsoft.com/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) -**Token-Oriented Object Notation** is a compact, human-readable format designed for passing structured data to Large Language Models with significantly reduced token usage. +**Token-Oriented Object Notation** is a compact, human-readable encoding of the JSON data model that minimizes tokens and makes structure easy for models to follow. Combines YAML-like indentation with CSV-like tabular arrays. Fully compatible with the [official TOON specification v3.0](https://github.com/toon-format/spec). -## Status +**Key Features:** Minimal syntax • TOON Encoding and Decoding • Tabular arrays for uniform data • Path expansion • Strict mode validation • .NET 8.0, 9.0 and 10.0 • 520+ tests with 99.7% spec coverage. -🚧 **This package is currently a namespace reservation.** Full implementation coming soon! +## Quick Start -### Example +```csharp +using Toon.Format; + +var data = new +{ + users = new[] + { + new { id = 1, name = "Alice", role = "admin" }, + new { id = 2, name = "Bob", role = "user" } + } +}; + +Console.WriteLine(ToonEncoder.Encode(data)); +``` + +**Output:** + +``` +users[2]{id,name,role}: + 1,Alice,admin + 2,Bob,user +``` + +**Compared to JSON (30-60% token reduction):** -**JSON** (verbose): ```json { "users": [ @@ -22,35 +44,232 @@ } ``` -**TOON** (compact): +## Installation + +```bash +dotnet add package Toon.Format +``` + +## Type Conversions + +.NET-specific types are automatically normalized for LLM-safe output: + +| Input Type | Output | +| --- | --- | +| Number (finite) | Decimal form; `-0` → `0`; no scientific notation | +| Number (`NaN`, `±Infinity`) | `null` | +| `decimal`, `double`, `float` | Decimal number | +| `DateTime`, `DateTimeOffset` | ISO 8601 string in quotes | +| `Guid` | String in quotes | +| `IDictionary<,>`, `Dictionary<,>` | Object with string keys | +| `IEnumerable<>`, arrays | Arrays | +| Nullable types | Unwrapped value or `null` | + +## API + +### `ToonEncoder.Encode(object value): string` + +### `ToonEncoder.Encode(object value, ToonEncodeOptions options): string` + +Converts any .NET object to TOON format. + +**Parameters:** + +- `value` – Any .NET object (class, record, dictionary, list, or primitive). Non-serializable values are converted to `null`. DateTime types are converted to ISO strings. +- `options` – Optional encoding options: + - `Indent` – Number of spaces per indentation level (default: `2`) + - `Delimiter` – Delimiter for array values: `ToonDelimiter.COMMA` (default), `TAB`, or `PIPE` + - `KeyFolding` – Collapse nested single-key objects: `ToonKeyFolding.Off` or `Safe` (default: `Off`) + +**Returns:** + +A TOON-formatted string with no trailing newline or spaces. + +**Example:** + +```csharp +using Toon.Format; + +record Item(string Sku, int Qty, double Price); +record Data(List Items); + +var item1 = new Item("A1", 2, 9.99); +var item2 = new Item("B2", 1, 14.5); +var data = new Data(new List { item1, item2 }); + +Console.WriteLine(ToonEncoder.Encode(data)); +``` + +**Output:** + +``` +Items[2]{Sku,Qty,Price}: + A1,2,9.99 + B2,1,14.5 +``` + +#### Delimiter Options + +Alternative delimiters can provide additional token savings: + +**Tab Delimiter:** + +```csharp +var options = new ToonEncodeOptions +{ + Delimiter = ToonDelimiter.TAB +}; +Console.WriteLine(ToonEncoder.Encode(data, options)); +``` + +**Output:** + +``` +Items[2 ]{Sku Qty Price}: + A1 2 9.99 + B2 1 14.5 ``` + +**Pipe Delimiter:** + +```csharp +var options = new ToonEncodeOptions +{ + Delimiter = ToonDelimiter.PIPE +}; +Console.WriteLine(ToonEncoder.Encode(data, options)); +``` + +**Output:** + +``` +Items[2|]{Sku|Qty|Price}: + A1|2|9.99 + B2|1|14.5 +``` + +#### Key Folding + +Collapse nested single-key objects for more compact output: + +```csharp +var data = new { user = new { profile = new { name = "Alice" } } }; + +var options = new ToonEncodeOptions +{ + KeyFolding = ToonKeyFolding.Safe +}; +Console.WriteLine(ToonEncoder.Encode(data, options)); +// Output: user.profile.name: Alice +``` + +### `ToonDecoder.Decode(string toon): JsonNode` + +### `ToonDecoder.Decode(string toon, ToonDecodeOptions options): JsonNode` + +### `ToonDecoder.Decode(string toon): T` + +### `ToonDecoder.Decode(string toon, ToonDecodeOptions options): T` + +Converts TOON-formatted strings back to .NET objects. + +**Parameters:** + +- `toon` – TOON-formatted input string +- `options` – Optional decoding options: + - `Indent` – Number of spaces per indentation level (default: `2`) + - `Strict` – Enable validation mode (default: `true`). When `true`, throws `ToonFormatException` on invalid input. + - `ExpandPaths` – Expand dotted keys: `ToonPathExpansion.Off` (default) or `ToonPathExpansion.Safe` + +**Returns:** + +For generic overloads: Returns a `JsonNode` (JsonObject, JsonArray, or JsonValue) or deserialized type `T`. + +**Example:** + +```csharp +using Toon.Format; + +string toon = """ users[2]{id,name,role}: 1,Alice,admin 2,Bob,user +"""; + +// Decode to JsonNode +var result = ToonDecoder.Decode(toon); + +// Decode to specific type +var users = ToonDecoder.Decode>(toon); ``` -## Resources +#### Path Expansion + +Expand dotted keys into nested objects: -- [TOON Specification](https://github.com/toon-format/spec/blob/main/SPEC.md) -- [Main Repository](https://github.com/toon-format/toon) -- [Benchmarks & Performance](https://github.com/toon-format/toon#benchmarks) -- [Other Language Implementations](https://github.com/toon-format/toon#other-implementations) +```csharp +string toon = "a.b.c: 1"; + +var options = new ToonDecodeOptions +{ + ExpandPaths = ToonPathExpansion.Safe +}; -## Future Usage +var result = ToonDecoder.Decode(toon, options); +// Result: { "a": { "b": { "c": 1 } } } +``` -Once implemented, the package will provide: +#### Round-Trip Conversion ```csharp using Toon.Format; -var data = // your data structure -var toonString = ToonEncoder.Encode(data); -var decoded = ToonDecoder.Decode(toonString); +// Original data +var data = new +{ + id = 123, + name = "Ada", + tags = new[] { "dev", "admin" } +}; + +// Encode to TOON +string toon = ToonEncoder.Encode(data); + +// Decode back to objects +var decoded = ToonDecoder.Decode(toon); + +// Or decode to specific type +var typed = ToonDecoder.Decode(toon); ``` +For more examples and options, see the [tests](./tests/ToonFormat.Tests/). + +## Project Status + +**This project is 100% compliant with TOON Specification v3.0** + +This implementation: +- Passes 370+ specification tests (100% coverage) +- Supports all TOON v3.0 features +- Handles all edge cases and strict mode validations +- Fully documented with XML comments +- Production-ready for .NET 8.0, .NET 9.0 and .NET 10.0 + +See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. + +## Documentation + +- [📘 TOON Specification v3.0](https://github.com/toon-format/spec/blob/main/SPEC.md) - Official specification +- [🔧 API Tests](./tests/ToonFormat.Tests/) - Comprehensive test suite with examples +- [📋 Project Plan](SPEC_V3_PROJECT_PLAN.md) - Implementation details and compliance checklist +- [🤝 Contributing](CONTRIBUTING.md) - Contribution guidelines +- [🏠 Main Repository](https://github.com/toon-format/toon) - TOON format home +- [📊 Benchmarks](https://github.com/toon-format/toon#benchmarks) - Performance comparisons +- [🌐 Other Implementations](https://github.com/toon-format/toon#other-implementations) - TypeScript, Java, Python, etc. + ## Contributing -Interested in implementing TOON for .NET? Check out the [specification](https://github.com/toon-format/spec/blob/main/SPEC.md) and feel free to contribute! +Interested in contributing? Check out the [specification](https://github.com/toon-format/spec/blob/main/SPEC.md) and [contribution guidelines](CONTRIBUTING.md)! ## License diff --git a/specgen.ps1 b/specgen.ps1 index 9b582ee..828a7b4 100644 --- a/specgen.ps1 +++ b/specgen.ps1 @@ -1,8 +1,8 @@ # generate spec $GH_REPO = "https://github.com/toon-format/spec.git" -$OUT_DIR = "./tests/ToonFormat.Tests" +$OUT_DIR = "./tests/ToonFormat.Tests/GeneratedTests" # build and execute spec generator dotnet build tests/ToonFormat.SpecGenerator -dotnet run --project tests/ToonFormat.SpecGenerator -- --url="$GH_REPO" --output="$OUT_DIR" --branch="v2.0.0" --loglevel="Information" +dotnet run --project tests/ToonFormat.SpecGenerator -- --url="$GH_REPO" --output="$OUT_DIR" --branch="v3.0.0" --loglevel="Information" diff --git a/specgen.sh b/specgen.sh old mode 100644 new mode 100755 index bbcdc96..4c2a379 --- a/specgen.sh +++ b/specgen.sh @@ -1,8 +1,8 @@ -# generate spec -GH_REPO="https://github.com/toon-format/spec.git" -OUT_DIR="./tests/ToonFormat.Tests" - -# build and execute spec generator -dotnet build tests/ToonFormat.SpecGenerator - -dotnet run --project tests/ToonFormat.SpecGenerator -- --url="$GH_REPO" --output="$OUT_DIR" --branch="v2.0.0" --loglevel="Information" \ No newline at end of file +# generate spec +GH_REPO="https://github.com/toon-format/spec.git" +OUT_DIR="./tests/ToonFormat.Tests/GeneratedTests" + +# build and execute spec generator +dotnet build tests/ToonFormat.SpecGenerator + +dotnet run --project tests/ToonFormat.SpecGenerator -- --url="$GH_REPO" --output="$OUT_DIR" --branch="v3.0.0" --loglevel="Information" \ No newline at end of file diff --git a/src/ToonFormat/Constants.cs b/src/ToonFormat/Constants.cs index a16ec88..b713b0e 100644 --- a/src/ToonFormat/Constants.cs +++ b/src/ToonFormat/Constants.cs @@ -1,44 +1,67 @@ using System; -namespace ToonFormat +namespace Toon.Format { - + /// + /// TOON format constants for structural characters, literals, and delimiters. + /// public static class Constants { + /// The list item marker character (-). public const char LIST_ITEM_MARKER = '-'; + /// The list item prefix string ("- "). public const string LIST_ITEM_PREFIX = "- "; // #region Structural characters + /// Comma delimiter character (,). public const char COMMA = ','; + /// Colon separator character (:). public const char COLON = ':'; + /// Space character. public const char SPACE = ' '; + /// Pipe delimiter character (|). public const char PIPE = '|'; + /// Hash/pound character (#). public const char HASH = '#'; + /// Dot/period character (.). public const char DOT = '.'; // #endregion // #region Brackets and braces + /// Opening square bracket ([). public const char OPEN_BRACKET = '['; + /// Closing square bracket (]). public const char CLOSE_BRACKET = ']'; + /// Opening curly brace ({). public const char OPEN_BRACE = '{'; + /// Closing curly brace (}). public const char CLOSE_BRACE = '}'; // #endregion // #region Literals + /// Null literal string ("null"). public const string NULL_LITERAL = "null"; + /// True literal string ("true"). public const string TRUE_LITERAL = "true"; + /// False literal string ("false"). public const string FALSE_LITERAL = "false"; // #endregion // #region Escape/control characters + /// Backslash escape character (\). public const char BACKSLASH = '\\'; + /// Double quote character ("). public const char DOUBLE_QUOTE = '"'; + /// Newline character (\n). public const char NEWLINE = '\n'; + /// Carriage return character (\r). public const char CARRIAGE_RETURN = '\r'; + /// Tab character (\t). public const char TAB = '\t'; // #region Delimiter defaults and mapping + /// Default delimiter enum value (COMMA). public const ToonDelimiter DEFAULT_DELIMITER_ENUM = ToonDelimiter.COMMA; /// Default delimiter character (comma). @@ -102,4 +125,16 @@ public enum ToonKeyFolding Safe } + /// + /// Path expansion options + /// + public enum ToonPathExpansion + { + /// Path expansion disabled + Off, + + /// Keys containing dots are expanded into nested structures + Safe + } + } diff --git a/src/ToonFormat/Internal/Converters/DoubleNamedFloatToNullConverter.cs b/src/ToonFormat/Internal/Converters/DoubleNamedFloatToNullConverter.cs deleted file mode 100644 index 7e0c6a5..0000000 --- a/src/ToonFormat/Internal/Converters/DoubleNamedFloatToNullConverter.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace ToonFormat.Internal.Converters -{ - /// - /// Normalizes double NaN/Infinity to null when writing JSON, keeping original numeric precision otherwise. - /// Reading still uses default handling, no special conversion. - /// Purpose: Consistent with TS spec (NaN/±Infinity -> null), and provides stable JsonElement for subsequent TOON encoding phase. - /// - internal sealed class DoubleNamedFloatToNullConverter : JsonConverter - { - public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => reader.GetDouble(); - - public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) - { - if (double.IsNaN(value) || double.IsInfinity(value)) - { - writer.WriteNullValue(); - return; - } - writer.WriteNumberValue(value); - } - } -} diff --git a/src/ToonFormat/Internal/Converters/SingleNamedFloatToNullConverter.cs b/src/ToonFormat/Internal/Converters/SingleNamedFloatToNullConverter.cs deleted file mode 100644 index abd1eff..0000000 --- a/src/ToonFormat/Internal/Converters/SingleNamedFloatToNullConverter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace ToonFormat.Internal.Converters -{ - /// - /// Normalizes float NaN/Infinity to null when writing JSON; reading keeps default behavior. - /// - internal sealed class SingleNamedFloatToNullConverter : JsonConverter - { - public override float Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => reader.GetSingle(); - - public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options) - { - if (float.IsNaN(value) || float.IsInfinity(value)) - { - writer.WriteNullValue(); - return; - } - writer.WriteNumberValue(value); - } - } -} diff --git a/src/ToonFormat/Internal/Decode/Decoders.cs b/src/ToonFormat/Internal/Decode/Decoders.cs index abf6587..2c09ac0 100644 --- a/src/ToonFormat/Internal/Decode/Decoders.cs +++ b/src/ToonFormat/Internal/Decode/Decoders.cs @@ -3,9 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json.Nodes; -using ToonFormat.Internal.Shared; +using Toon.Format.Internal.Shared; -namespace ToonFormat.Internal.Decode +namespace Toon.Format.Internal.Decode { /// /// Main decoding functions for converting TOON format to JSON values. @@ -18,7 +18,10 @@ internal static class Decoders /// /// Decodes TOON content from a line cursor into a JSON value. /// - public static JsonNode? DecodeValueFromLines(LineCursor cursor, ResolvedDecodeOptions options) + /// The line cursor for reading input + /// Decoding options + /// Optional set to populate with keys that were quoted in the source + public static JsonNode? DecodeValueFromLines(LineCursor cursor, ResolvedDecodeOptions options, HashSet? quotedKeys = null) { var first = cursor.Peek(); if (first == null) @@ -44,7 +47,7 @@ internal static class Decoders } // Default to object - return DecodeObject(cursor, 0, options); + return DecodeObject(cursor, 0, options, quotedKeys); } private static bool IsKeyValueLine(ParsedLine line) @@ -72,7 +75,7 @@ private static bool IsKeyValueLine(ParsedLine line) // #region Object decoding - private static JsonObject DecodeObject(LineCursor cursor, int baseDepth, ResolvedDecodeOptions options) + private static JsonObject DecodeObject(LineCursor cursor, int baseDepth, ResolvedDecodeOptions options, HashSet? quotedKeys = null) { var obj = new JsonObject(); @@ -92,8 +95,14 @@ private static JsonObject DecodeObject(LineCursor cursor, int baseDepth, Resolve if (line.Depth == computedDepth) { - var (key, value) = DecodeKeyValuePair(line, cursor, computedDepth.Value, options); + var (key, value, wasQuoted) = DecodeKeyValuePair(line, cursor, computedDepth.Value, options); obj[key] = value; + + // Track quoted keys at the root level + if (wasQuoted && quotedKeys != null && baseDepth == 0) + { + quotedKeys.Add(key); + } } else { @@ -110,25 +119,41 @@ private class KeyValueDecodeResult public string Key { get; set; } = string.Empty; public JsonNode? Value { get; set; } public int FollowDepth { get; set; } + public bool WasQuoted { get; set; } } + /// + /// Decodes a key-value pair from a line of TOON content. + /// Per SPEC v3.0 §10: When decoding an array that is the first field of a list item + /// (isListItemFirstField=true), the array contents are expected at depth +2 relative + /// to the hyphen line. This method adjusts the effective depth accordingly. + /// private static KeyValueDecodeResult DecodeKeyValue( string content, LineCursor cursor, int baseDepth, - ResolvedDecodeOptions options) + + ResolvedDecodeOptions options, + bool isListItemFirstField = false) { // Check for array header first (before parsing key) var arrayHeader = Parser.ParseArrayHeaderLine(content, Constants.DEFAULT_DELIMITER_CHAR); if (arrayHeader != null && arrayHeader.Header.Key != null) { - var value = DecodeArrayFromHeader(arrayHeader.Header, arrayHeader.InlineValues, cursor, baseDepth, options); + // SPEC v3.0 §10: Arrays (tabular or list) on the hyphen line MUST appear at depth +2 + // Normal arrays are at depth +1 relative to header. + // So if we are on the hyphen line (isListItemFirstField), we treat baseDepth as +1 higher + // so that the array decoder looks for items at (baseDepth + 1) + 1 = baseDepth + 2. + var effectiveDepth = isListItemFirstField ? baseDepth + 1 : baseDepth; + + var value = DecodeArrayFromHeader(arrayHeader.Header, arrayHeader.InlineValues, cursor, effectiveDepth, options); // After an array, subsequent fields are at baseDepth + 1 (where array content is) return new KeyValueDecodeResult { Key = arrayHeader.Header.Key, Value = value, - FollowDepth = baseDepth + 1 + FollowDepth = baseDepth + 1, + WasQuoted = false // Array headers are never quoted in the key part }; } @@ -143,18 +168,18 @@ private static KeyValueDecodeResult DecodeKeyValue( if (nextLine != null && nextLine.Depth > baseDepth) { var nested = DecodeObject(cursor, baseDepth + 1, options); - return new KeyValueDecodeResult { Key = keyResult.Key, Value = nested, FollowDepth = baseDepth + 1 }; + return new KeyValueDecodeResult { Key = keyResult.Key, Value = nested, FollowDepth = baseDepth + 1, WasQuoted = keyResult.WasQuoted }; } // Empty object - return new KeyValueDecodeResult { Key = keyResult.Key, Value = new JsonObject(), FollowDepth = baseDepth + 1 }; + return new KeyValueDecodeResult { Key = keyResult.Key, Value = new JsonObject(), FollowDepth = baseDepth + 1, WasQuoted = keyResult.WasQuoted }; } // Inline primitive value var primitiveValue = Parser.ParsePrimitiveToken(rest); - return new KeyValueDecodeResult { Key = keyResult.Key, Value = primitiveValue, FollowDepth = baseDepth + 1 }; + return new KeyValueDecodeResult { Key = keyResult.Key, Value = primitiveValue, FollowDepth = baseDepth + 1, WasQuoted = keyResult.WasQuoted }; } - private static (string key, JsonNode? value) DecodeKeyValuePair( + private static (string key, JsonNode? value, bool wasQuoted) DecodeKeyValuePair( ParsedLine line, LineCursor cursor, int baseDepth, @@ -162,7 +187,7 @@ private static (string key, JsonNode? value) DecodeKeyValuePair( { cursor.Advance(); var result = DecodeKeyValue(line.Content, cursor, baseDepth, options); - return (result.Key, result.Value); + return (result.Key, result.Value, result.WasQuoted); } // #endregion @@ -410,6 +435,11 @@ private static List DecodeTabularArray( return Parser.ParsePrimitiveToken(afterHyphen); } + /// + /// Decodes an object from a list item, handling the first field specially. + /// Per SPEC v3.0 §10: The first field may be an array on the hyphen line, + /// in which case its contents appear at depth +2 and sibling fields at depth +1. + /// private static JsonObject DecodeObjectFromListItem( ParsedLine firstLine, LineCursor cursor, @@ -417,7 +447,7 @@ private static JsonObject DecodeObjectFromListItem( ResolvedDecodeOptions options) { var afterHyphen = firstLine.Content.Substring(Constants.LIST_ITEM_PREFIX.Length); - var firstField = DecodeKeyValue(afterHyphen, cursor, baseDepth, options); + var firstField = DecodeKeyValue(afterHyphen, cursor, baseDepth, options, isListItemFirstField: true); var obj = new JsonObject { [firstField.Key] = firstField.Value }; @@ -430,7 +460,7 @@ private static JsonObject DecodeObjectFromListItem( if (line.Depth == firstField.FollowDepth && !line.Content.StartsWith(Constants.LIST_ITEM_PREFIX)) { - var (k, v) = DecodeKeyValuePair(line, cursor, firstField.FollowDepth, options); + var (k, v, _) = DecodeKeyValuePair(line, cursor, firstField.FollowDepth, options); obj[k] = v; } else diff --git a/src/ToonFormat/Internal/Decode/Parser.cs b/src/ToonFormat/Internal/Decode/Parser.cs index b65642f..1f212d4 100644 --- a/src/ToonFormat/Internal/Decode/Parser.cs +++ b/src/ToonFormat/Internal/Decode/Parser.cs @@ -4,9 +4,9 @@ using System.Globalization; using System.Linq; using System.Text.Json.Nodes; -using ToonFormat.Internal.Shared; +using Toon.Format.Internal.Shared; -namespace ToonFormat.Internal.Decode +namespace Toon.Format.Internal.Decode { /// /// Information about an array header. @@ -277,6 +277,11 @@ public static List ParseDelimitedValues(string input, char delimiter) { var parsedNumber = double.Parse(trimmed, CultureInfo.InvariantCulture); parsedNumber = FloatUtils.NormalizeSignedZero(parsedNumber); + if (parsedNumber < 1e-6 || parsedNumber > 1e6) + { + return JsonValue.Create(NumericUtils.EmitCanonicalDecimalForm(parsedNumber)); + } + return JsonValue.Create(parsedNumber); } @@ -317,6 +322,7 @@ public class KeyParseResult { public string Key { get; set; } = string.Empty; public int End { get; set; } + public bool WasQuoted { get; set; } } public static KeyParseResult ParseUnquotedKey(string content, int start) @@ -338,7 +344,7 @@ public static KeyParseResult ParseUnquotedKey(string content, int start) // Skip the colon end++; - return new KeyParseResult { Key = key, End = end }; + return new KeyParseResult { Key = key, End = end, WasQuoted = false }; } public static KeyParseResult ParseQuotedKey(string content, int start) @@ -361,9 +367,10 @@ public static KeyParseResult ParseQuotedKey(string content, int start) { throw ToonFormatException.Syntax("Missing colon after key"); } + end++; - return new KeyParseResult { Key = key, End = end }; + return new KeyParseResult { Key = key, End = end, WasQuoted = true }; } /// @@ -391,7 +398,7 @@ public static KeyParseResult ParseKeyToken(string content, int start) public static bool IsArrayHeaderAfterHyphen(string content) { return content.Trim().StartsWith(Constants.OPEN_BRACKET.ToString()) - && StringUtils.FindUnquotedChar(content, Constants.COLON) != -1; + && StringUtils.FindUnquotedChar(content, Constants.COLON) != -1; } /// @@ -404,4 +411,4 @@ public static bool IsObjectFirstFieldAfterHyphen(string content) // #endregion } -} +} \ No newline at end of file diff --git a/src/ToonFormat/Internal/Decode/PathExpansion.cs b/src/ToonFormat/Internal/Decode/PathExpansion.cs new file mode 100644 index 0000000..d9dbaee --- /dev/null +++ b/src/ToonFormat/Internal/Decode/PathExpansion.cs @@ -0,0 +1,185 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Toon.Format.Internal.Shared; + +namespace Toon.Format.Internal.Decode +{ + /// + /// Path expansion logic for dotted keys per SPEC §13.4 + /// + internal static class PathExpansion + { + /// + /// Expands dotted keys in a JsonObject into nested structures. + /// Example: {"a.b.c": 1} -> {"a": {"b": {"c": 1}}} + /// + /// The object to expand + /// Whether to throw on conflicts + /// Set of keys that were originally quoted (should not be expanded) + public static JsonObject ExpandPaths(JsonObject obj, bool strict, HashSet? quotedKeys = null) + { + var result = new JsonObject(); + + foreach (var kvp in obj) + { + var key = kvp.Key; + var value = kvp.Value; + + // Skip expansion for quoted keys (they should remain as literal dotted keys) + bool wasQuoted = quotedKeys != null && quotedKeys.Contains(key); + + // Check if key contains dots and is eligible for expansion + if (!wasQuoted && key.Contains(Constants.DOT) && IsExpandable(key)) + { + // Split and expand + var segments = key.Split(Constants.DOT); + SetNestedValue(result, segments, value, strict); + } + else + { + // Not expandable or was quoted, set directly + SetValue(result, key, value, strict); + } + } + + return result; + } + + /// + /// Checks if a dotted key is eligible for expansion. + /// All segments must be valid identifiers. + /// + private static bool IsExpandable(string key) + { + var segments = key.Split(Constants.DOT); + return segments.All(segment => ValidationShared.IsIdentifierSegment(segment)); + } + + /// + /// Sets a nested value by traversing/creating the path + /// + private static void SetNestedValue(JsonObject target, string[] segments, JsonNode? value, bool strict) + { + var current = target; + + for (int i = 0; i < segments.Length - 1; i++) + { + var segment = segments[i]; + + if (current.ContainsKey(segment)) + { + var existing = current[segment]; + + if (existing is JsonObject existingObj) + { + // Continue traversing + current = existingObj; + } + else + { + // Conflict: path requires object but found non-object + if (strict) + { + throw ToonPathExpansionException.TraversalConflict( + segment, + GetTypeName(existing), + string.Join(".", segments), + i + ); + } + else + { + // LWW: replace with new object + var newObj = new JsonObject(); + current[segment] = newObj; + current = newObj; + } + } + } + else + { + // Create new object at this segment + var newObj = new JsonObject(); + current[segment] = newObj; + current = newObj; + } + } + + // Set the final value + var lastSegment = segments[segments.Length - 1]; + SetValue(current, lastSegment, value, strict); + } + + /// + /// Sets a value in an object, handling conflicts + /// + private static void SetValue(JsonObject target, string key, JsonNode? value, bool strict) + { + if (target.ContainsKey(key)) + { + var existing = target[key]; + + // Check for conflicts + bool conflict = false; + + if (value is JsonObject && !(existing is JsonObject)) + { + conflict = true; + } + else if (!(value is JsonObject) && existing is JsonObject) + { + conflict = true; + } + + if (conflict) + { + if (strict) + { + throw ToonPathExpansionException.AssignmentConflict( + key, + GetTypeName(value), + GetTypeName(existing) + ); + } + // LWW: just overwrite + } + + // If both are objects, deep merge + if (value is JsonObject valueObj && existing is JsonObject existingObj) + { + DeepMerge(existingObj, valueObj, strict); + return; + } + } + + // Set or overwrite + target[key] = value?.DeepClone(); + } + + /// + /// Deep merges source into target + /// + private static void DeepMerge(JsonObject target, JsonObject source, bool strict) + { + foreach (var kvp in source) + { + SetValue(target, kvp.Key, kvp.Value, strict); + } + } + + /// + /// Gets a human-readable type name for error messages + /// + private static string GetTypeName(JsonNode? node) + { + if (node == null) return "null"; + if (node is JsonObject) return "object"; + if (node is JsonArray) return "array"; + return "primitive"; + } + } +} diff --git a/src/ToonFormat/Internal/Decode/Scanner.cs b/src/ToonFormat/Internal/Decode/Scanner.cs index b14c27d..f74e7c1 100644 --- a/src/ToonFormat/Internal/Decode/Scanner.cs +++ b/src/ToonFormat/Internal/Decode/Scanner.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; -namespace ToonFormat.Internal.Decode +namespace Toon.Format.Internal.Decode { /// /// Represents a parsed line with its raw content, indentation, depth, and line number. diff --git a/src/ToonFormat/Internal/Decode/Validation.cs b/src/ToonFormat/Internal/Decode/Validation.cs index ebeeef4..70bb38c 100644 --- a/src/ToonFormat/Internal/Decode/Validation.cs +++ b/src/ToonFormat/Internal/Decode/Validation.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; -namespace ToonFormat.Internal.Decode +namespace Toon.Format.Internal.Decode { /// /// Options for decoding TOON format. @@ -12,6 +12,7 @@ internal class ResolvedDecodeOptions { public int Indent { get; set; } = 2; public bool Strict { get; set; } = false; + public ToonPathExpansion ExpandPaths { get; set; } = ToonPathExpansion.Off; } /// diff --git a/src/ToonFormat/Internal/Encode/Encoders.cs b/src/ToonFormat/Internal/Encode/Encoders.cs index 86d6a65..fb8be9a 100644 --- a/src/ToonFormat/Internal/Encode/Encoders.cs +++ b/src/ToonFormat/Internal/Encode/Encoders.cs @@ -5,7 +5,7 @@ using System.Linq; using System.Text.Json.Nodes; -namespace ToonFormat.Internal.Encode +namespace Toon.Format.Internal.Encode { /// /// Options for encoding TOON format, aligned with TypeScript ResolvedEncodeOptions. @@ -358,6 +358,9 @@ public static bool IsTabularArray( /// /// Writes tabular rows to the writer. + /// The depth parameter determines the indentation level of the rows. + /// Per SPEC v3.0 §10: When writing rows for a tabular array on a hyphen line, + /// depth should be +2 relative to the hyphen line (not +1 as in normal cases). /// private static void WriteTabularRows( IReadOnlyList rows, @@ -398,6 +401,9 @@ public static void EncodeMixedArrayAsListItems( /// /// Encodes an object as a list item with special formatting for the first property. + /// Per SPEC v3.0 §10: When the first field is an array (tabular or list), the array header + /// appears on the hyphen line, array contents appear at depth +2, and sibling fields at depth +1. + /// This ensures visual clarity and LLM readability for nested structures. /// public static void EncodeObjectAsListItem(JsonObject obj, LineWriter writer, int depth, ResolvedEncodeOptions options) { @@ -439,7 +445,8 @@ public static void EncodeObjectAsListItem(JsonObject obj, LineWriter writer, int // Tabular format for uniform arrays of objects var formattedHeader = Primitives.FormatHeader(arr.Count, firstKey, header, options.Delimiter); writer.PushListItem(depth, formattedHeader); - WriteTabularRows(objects, header, writer, depth + 1, options); + // SPEC v3.0 §10: Tabular rows MUST appear at depth +2 relative to the hyphen line + WriteTabularRows(objects, header, writer, depth + 2, options); } else { @@ -447,7 +454,7 @@ public static void EncodeObjectAsListItem(JsonObject obj, LineWriter writer, int writer.PushListItem(depth, $"{encodedKey}{Constants.OPEN_BRACKET}{arr.Count}{Constants.CLOSE_BRACKET}{Constants.COLON}"); foreach (var itemObj in arr.OfType()) { - EncodeObjectAsListItem(itemObj, writer, depth + 1, options); + EncodeObjectAsListItem(itemObj, writer, depth + 2, options); } } } @@ -456,10 +463,10 @@ public static void EncodeObjectAsListItem(JsonObject obj, LineWriter writer, int // Complex arrays on separate lines (array of arrays, etc.) writer.PushListItem(depth, $"{encodedKey}{Constants.OPEN_BRACKET}{arr.Count}{Constants.CLOSE_BRACKET}{Constants.COLON}"); - // Encode array contents at depth + 1 + // Encode array contents at depth + 2 (SPEC v3.0 §10) foreach (var item in arr) { - EncodeListItemValue(item, writer, depth + 1, options); + EncodeListItemValue(item, writer, depth + 2, options); } } } @@ -513,6 +520,34 @@ private static void EncodeListItemValue( { EncodeObjectAsListItem((JsonObject)value!, writer, depth, options); } + else if (Normalize.IsJsonArray(value)) + { + var arr = (JsonArray)value!; + // Complex array (e.g. array of objects, or array of arrays) as a list item value + + // Check for tabular + if (Normalize.IsArrayOfObjects(arr)) + { + var objects = arr.Cast().ToList(); + var header = ExtractTabularHeader(objects); + if (header != null) + { + var formattedHeader = Primitives.FormatHeader(arr.Count, null, header, options.Delimiter); + writer.PushListItem(depth, formattedHeader); + WriteTabularRows(objects, header, writer, depth + 2, options); + return; + } + } + + // Fallback for non-tabular or mixed + var headerStr = Primitives.FormatHeader(arr.Count, null, null, options.Delimiter); + writer.PushListItem(depth, headerStr); + + foreach (var item in arr) + { + EncodeListItemValue(item, writer, depth + 1, options); + } + } } // #endregion diff --git a/src/ToonFormat/Internal/Encode/Folding.cs b/src/ToonFormat/Internal/Encode/Folding.cs index 1f89e33..fd2825f 100644 --- a/src/ToonFormat/Internal/Encode/Folding.cs +++ b/src/ToonFormat/Internal/Encode/Folding.cs @@ -4,9 +4,9 @@ using System.Text; using System.Text.Json.Nodes; using System.Threading.Tasks; -using ToonFormat.Internal.Shared; +using Toon.Format.Internal.Shared; -namespace ToonFormat.Internal.Encode +namespace Toon.Format.Internal.Encode { internal class FoldResult { diff --git a/src/ToonFormat/Internal/Encode/LineWriter.cs b/src/ToonFormat/Internal/Encode/LineWriter.cs index fb3409f..39acac6 100644 --- a/src/ToonFormat/Internal/Encode/LineWriter.cs +++ b/src/ToonFormat/Internal/Encode/LineWriter.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Text; -namespace ToonFormat.Internal.Encode +namespace Toon.Format.Internal.Encode { /// /// Helper class for building indented lines of TOON output. diff --git a/src/ToonFormat/Internal/Encode/Normalize.cs b/src/ToonFormat/Internal/Encode/Normalize.cs index a9f2b2c..762b6f7 100644 --- a/src/ToonFormat/Internal/Encode/Normalize.cs +++ b/src/ToonFormat/Internal/Encode/Normalize.cs @@ -5,9 +5,9 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; -using ToonFormat.Internal.Shared; +using Toon.Format.Internal.Shared; -namespace ToonFormat.Internal.Encode +namespace Toon.Format.Internal.Encode { /// /// Normalization utilities for converting arbitrary .NET objects to JsonNode representations @@ -72,18 +72,7 @@ internal static class Normalize if (value is DateTimeOffset dto) return JsonValue.Create(dto.ToString("O")); - // Array/List → JsonArray - if (value is IEnumerable enumerable && value is not string) - { - var jsonArray = new JsonArray(); - foreach (var item in enumerable) - { - jsonArray.Add(NormalizeValue(item)); - } - return jsonArray; - } - - // Dictionary/Object → JsonObject + // Dictionary/Object → JsonObject (check BEFORE IEnumerable since IDictionary implements IEnumerable) if (value is IDictionary dict) { var jsonObject = new JsonObject(); @@ -95,6 +84,17 @@ internal static class Normalize return jsonObject; } + // Array/List → JsonArray + if (value is IEnumerable enumerable && value is not string) + { + var jsonArray = new JsonArray(); + foreach (var item in enumerable) + { + jsonArray.Add(NormalizeValue(item)); + } + return jsonArray; + } + // Plain object → JsonObject via reflection if (IsPlainObject(value)) { @@ -164,17 +164,7 @@ internal static class Normalize return JsonValue.Create(dto.ToString("O")); } - // Collections / dictionaries (pattern checks avoid boxing for value-type T that implement the interfaces) - if (value is IEnumerable enumerable && value is not string) - { - var jsonArray = new JsonArray(); - foreach (var item in enumerable) - { - jsonArray.Add(NormalizeValue(item)); - } - return jsonArray; - } - + // Collections / dictionaries (check IDictionary BEFORE IEnumerable since IDictionary implements IEnumerable) if (value is IDictionary dict) { var jsonObject = new JsonObject(); @@ -186,6 +176,16 @@ internal static class Normalize return jsonObject; } + if (value is IEnumerable enumerable && value is not string) + { + var jsonArray = new JsonArray(); + foreach (var item in enumerable) + { + jsonArray.Add(NormalizeValue(item)); + } + return jsonArray; + } + // Plain object via reflection (boxing for value types here is acceptable and rare) if (IsPlainObject(value!)) { diff --git a/src/ToonFormat/Internal/Encode/Primitives.cs b/src/ToonFormat/Internal/Encode/Primitives.cs index ccb9016..46839d7 100644 --- a/src/ToonFormat/Internal/Encode/Primitives.cs +++ b/src/ToonFormat/Internal/Encode/Primitives.cs @@ -4,9 +4,9 @@ using System.Globalization; using System.Linq; using System.Text.Json.Nodes; -using ToonFormat.Internal.Shared; +using Toon.Format.Internal.Shared; -namespace ToonFormat.Internal.Encode +namespace Toon.Format.Internal.Encode { /// /// Primitive value encoding, key encoding, and header formatting utilities. @@ -14,6 +14,54 @@ namespace ToonFormat.Internal.Encode /// internal static class Primitives { + /// + /// Formats a double value in non-exponential decimal form per SPEC v3.0 §2. + /// Converts -0 to 0, and ensures no scientific notation (e.g., 1E-06 → 0.000001). + /// Preserves up to 16 significant digits while removing spurious trailing zeros. + /// + private static string FormatNumber(double value) + { + // SPEC v3.0 §2: Convert -0 to 0 + if (value == 0.0) + return "0"; + + // Use G16 first to get the value with proper precision + var gFormat = value.ToString("G16", CultureInfo.InvariantCulture); + + // If it contains 'E' (scientific notation), convert to decimal format + if (gFormat.Contains('E') || gFormat.Contains('e')) + { + // Use "F" format with enough decimal places to preserve precision + // For very small numbers, we need sufficient decimal places + var absValue = Math.Abs(value); + int decimalPlaces = 0; + + if (absValue < 1.0 && absValue > 0.0) + { + // Calculate how many decimal places we need + decimalPlaces = Math.Max(0, -(int)Math.Floor(Math.Log10(absValue)) + 15); + } + else + { + decimalPlaces = 15; + } + + var result = value.ToString("F" + decimalPlaces, CultureInfo.InvariantCulture); + + // Remove trailing zeros after decimal point + if (result.Contains('.')) + { + result = result.TrimEnd('0'); + if (result.EndsWith('.')) + result = result.TrimEnd('.'); + } + + return result; + } + + return gFormat; + } + // #region Primitive encoding /// @@ -38,10 +86,7 @@ public static string EncodePrimitive(JsonNode? value, char delimiter = Constants return longVal.ToString(CultureInfo.InvariantCulture); if (jsonValue.TryGetValue(out var doubleVal)) - return doubleVal.ToString("G17", CultureInfo.InvariantCulture); - - if (jsonValue.TryGetValue(out var decimalVal)) - return decimalVal.ToString(CultureInfo.InvariantCulture); + return FormatNumber(doubleVal); // String if (jsonValue.TryGetValue(out var strVal)) diff --git a/src/ToonFormat/Internal/Shared/FloatUtils.cs b/src/ToonFormat/Internal/Shared/FloatUtils.cs index febd5e1..8241ff1 100644 --- a/src/ToonFormat/Internal/Shared/FloatUtils.cs +++ b/src/ToonFormat/Internal/Shared/FloatUtils.cs @@ -1,6 +1,6 @@ using System; -namespace ToonFormat.Internal.Shared +namespace Toon.Format.Internal.Shared { internal static class FloatUtils { @@ -14,13 +14,13 @@ internal static class FloatUtils /// public static bool NearlyEqual(double a, double b, double absEps = 1e-12, double relEps = 1e-9) { - if (double.IsNaN(a) && double.IsNaN(b)) return true; // ҵϳҪ NaN == NaN + if (double.IsNaN(a) && double.IsNaN(b)) return true; // ҵ���ϳ���Ҫ�� NaN == NaN if (double.IsInfinity(a) || double.IsInfinity(b)) return a.Equals(b); - if (a == b) return true; // 0.0 == -0.0ȫ + if (a == b) return true; // ���� 0.0 == -0.0����ȫ��� var diff = Math.Abs(a - b); var scale = Math.Max(Math.Abs(a), Math.Abs(b)); - if (scale == 0) return diff <= absEps; // ߶ӽ 0 + if (scale == 0) return diff <= absEps; // ���߶��ӽ� 0 return diff <= Math.Max(absEps, relEps * scale); } diff --git a/src/ToonFormat/Internal/Shared/LiteralUtils.cs b/src/ToonFormat/Internal/Shared/LiteralUtils.cs index 18f5ddc..36708c9 100644 --- a/src/ToonFormat/Internal/Shared/LiteralUtils.cs +++ b/src/ToonFormat/Internal/Shared/LiteralUtils.cs @@ -1,7 +1,7 @@ #nullable enable using System.Globalization; -namespace ToonFormat.Internal.Shared +namespace Toon.Format.Internal.Shared { /// /// Literal judgment utilities, aligned with TypeScript version shared/literal-utils.ts. diff --git a/src/ToonFormat/Internal/Shared/NumericUtils.cs b/src/ToonFormat/Internal/Shared/NumericUtils.cs new file mode 100644 index 0000000..770aa12 --- /dev/null +++ b/src/ToonFormat/Internal/Shared/NumericUtils.cs @@ -0,0 +1,48 @@ +using System.Text.RegularExpressions; + +namespace Toon.Format.Internal.Shared +{ + internal static class NumericUtils + { + /// + /// Converts a double to a decimal in canonical form for accurate representation. + /// + /// The input double value + /// A decimal representation of the input value. + /// https://github.com/toon-format/spec/blob/main/SPEC.md#2-data-model + /// 1e-7 => 0.0000001 + public static decimal EmitCanonicalDecimalForm(double value) + { + var scientificString = value.ToString("G17"); + var match = Regex.Match(scientificString, @"e[-+]\d+", RegexOptions.IgnoreCase); + + if (!match.Success) return (decimal)value; + + // The match is the exponent part, e.g., "E+04" + var exponentPart = match.Value; + + // Remove the 'E' or 'e' and the sign to get just the digits + var exponentDigits = exponentPart.Substring(2); + + // Parse the actual exponent value (4 in this example) + var exponent = int.Parse(exponentDigits); + + // You also need to check the sign to determine if it's positive or negative + if (exponentPart.Contains('-')) + { + exponent = -exponent; + } + + var mantissa = + scientificString.Substring(0, scientificString.IndexOf(match.Value, StringComparison.Ordinal)); + + var decimalValue = decimal.Parse(mantissa); + + if (exponent == 0) exponent++; + + decimalValue *= (decimal)Math.Pow(10, exponent); + + return decimalValue; + } + } +} \ No newline at end of file diff --git a/src/ToonFormat/Internal/Shared/StringUtils.cs b/src/ToonFormat/Internal/Shared/StringUtils.cs index 3677df6..8f3dd58 100644 --- a/src/ToonFormat/Internal/Shared/StringUtils.cs +++ b/src/ToonFormat/Internal/Shared/StringUtils.cs @@ -1,7 +1,7 @@ #nullable enable using System.Text; -namespace ToonFormat.Internal.Shared +namespace Toon.Format.Internal.Shared { /// /// String utilities, aligned with TypeScript version shared/string-utils.ts: @@ -21,6 +21,7 @@ internal static string EscapeString(string value) if (string.IsNullOrEmpty(value)) return value ?? string.Empty; return value + .Replace("\r\n", "\n") .Replace("\\", $"{Constants.BACKSLASH}{Constants.BACKSLASH}") .Replace("\"", $"{Constants.BACKSLASH}{Constants.DOUBLE_QUOTE}") .Replace("\n", $"{Constants.BACKSLASH}n") diff --git a/src/ToonFormat/Internal/Shared/ValidationShared.cs b/src/ToonFormat/Internal/Shared/ValidationShared.cs index 9db7a9c..825e9b0 100644 --- a/src/ToonFormat/Internal/Shared/ValidationShared.cs +++ b/src/ToonFormat/Internal/Shared/ValidationShared.cs @@ -1,9 +1,9 @@ #nullable enable using System; using System.Text.RegularExpressions; -using ToonFormat; +using Toon.Format; -namespace ToonFormat.Internal.Shared +namespace Toon.Format.Internal.Shared { /// /// Validation utilities aligned with TypeScript version shared/validation.ts: diff --git a/src/ToonFormat/Options/ToonDecodeOptions.cs b/src/ToonFormat/Options/ToonDecodeOptions.cs index 9451869..cfcedf9 100644 --- a/src/ToonFormat/Options/ToonDecodeOptions.cs +++ b/src/ToonFormat/Options/ToonDecodeOptions.cs @@ -17,4 +17,11 @@ public class ToonDecodeOptions /// Default is true. /// public bool Strict { get; set; } = true; + + /// + /// Controls path expansion for dotted keys. + /// (default): Dotted keys are treated as literal keys. + /// : Expand eligible dotted keys into nested objects. + /// + public ToonPathExpansion ExpandPaths { get; set; } = ToonPathExpansion.Off; } diff --git a/src/ToonFormat/Options/ToonEncodeOptions.cs b/src/ToonFormat/Options/ToonEncodeOptions.cs index d4b3733..d3b0a1b 100644 --- a/src/ToonFormat/Options/ToonEncodeOptions.cs +++ b/src/ToonFormat/Options/ToonEncodeOptions.cs @@ -1,5 +1,5 @@ #nullable enable -using ToonFormat; +using Toon.Format; namespace Toon.Format; diff --git a/src/ToonFormat/ToonDecoder.cs b/src/ToonFormat/ToonDecoder.cs index dc1551f..45fd406 100644 --- a/src/ToonFormat/ToonDecoder.cs +++ b/src/ToonFormat/ToonDecoder.cs @@ -6,8 +6,8 @@ using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; -using ToonFormat; -using ToonFormat.Internal.Decode; +using Toon.Format; +using Toon.Format.Internal.Decode; namespace Toon.Format; @@ -58,7 +58,8 @@ public static class ToonDecoder var resolvedOptions = new ResolvedDecodeOptions { Indent = options.Indent, - Strict = options.Strict + Strict = options.Strict, + ExpandPaths = options.ExpandPaths }; // Scan the source text into structured lines @@ -72,7 +73,23 @@ public static class ToonDecoder // Create cursor and decode var cursor = new LineCursor(scanResult.Lines, scanResult.BlankLines); - return Decoders.DecodeValueFromLines(cursor, resolvedOptions); + + // Track quoted keys if path expansion is enabled + HashSet? quotedKeys = null; + if (resolvedOptions.ExpandPaths == ToonPathExpansion.Safe) + { + quotedKeys = new HashSet(); + } + + var result = Decoders.DecodeValueFromLines(cursor, resolvedOptions, quotedKeys); + + // Apply path expansion if enabled + if (resolvedOptions.ExpandPaths == ToonPathExpansion.Safe && result is JsonObject obj) + { + result = PathExpansion.ExpandPaths(obj, resolvedOptions.Strict, quotedKeys); + } + + return result; } /// diff --git a/src/ToonFormat/ToonEncoder.cs b/src/ToonFormat/ToonEncoder.cs index 1e9ac7b..58a18ce 100644 --- a/src/ToonFormat/ToonEncoder.cs +++ b/src/ToonFormat/ToonEncoder.cs @@ -5,8 +5,8 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using ToonFormat; -using ToonFormat.Internal.Encode; +using Toon.Format; +using Toon.Format.Internal.Encode; namespace Toon.Format; diff --git a/src/ToonFormat/ToonFormat.csproj b/src/ToonFormat/ToonFormat.csproj index db718ab..acf9101 100644 --- a/src/ToonFormat/ToonFormat.csproj +++ b/src/ToonFormat/ToonFormat.csproj @@ -5,6 +5,7 @@ enable enable latest + Toon.Format Toon.Format diff --git a/src/ToonFormat/ToonFormatException.cs b/src/ToonFormat/ToonFormatException.cs index 9396875..7791b3e 100644 --- a/src/ToonFormat/ToonFormatException.cs +++ b/src/ToonFormat/ToonFormatException.cs @@ -2,7 +2,7 @@ using System; using System.Text; -namespace ToonFormat +namespace Toon.Format { /// /// Exception thrown when TOON format parsing or encoding fails. diff --git a/src/ToonFormat/ToonPathExpansionException.cs b/src/ToonFormat/ToonPathExpansionException.cs new file mode 100644 index 0000000..e40bdb6 --- /dev/null +++ b/src/ToonFormat/ToonPathExpansionException.cs @@ -0,0 +1,86 @@ +#nullable enable +using System; +using System.Text; + +namespace Toon.Format +{ + /// + /// Exception thrown when path expansion conflicts occur during TOON decoding. + /// + public sealed class ToonPathExpansionException : Exception + { + /// The key or path segment where the conflict occurred. + public string Key { get; } + + /// The full dotted path being expanded (if available). + public string? FullPath { get; } + + /// The type that was expected at this location. + public string ExpectedType { get; } + + /// The type that was actually found at this location. + public string ActualType { get; } + + /// Depth in the path where the conflict occurred (optional). + public int? Depth { get; } + + /// Constructs the exception with conflict details. + public ToonPathExpansionException( + string key, + string expectedType, + string actualType, + string? fullPath = null, + int? depth = null, + Exception? inner = null) + : base(BuildMessage(key, expectedType, actualType, fullPath, depth), inner) + { + Key = key; + ExpectedType = expectedType; + ActualType = actualType; + FullPath = fullPath; + Depth = depth; + } + + /// Factory method for path traversal conflicts. + public static ToonPathExpansionException TraversalConflict( + string segment, + string actualType, + string? fullPath = null, + int? depth = null) + => new(segment, "object", actualType, fullPath, depth); + + /// Factory method for key assignment conflicts. + public static ToonPathExpansionException AssignmentConflict( + string key, + string expectedType, + string actualType, + string? fullPath = null, + int? depth = null) + => new(key, expectedType, actualType, fullPath, depth); + + private static string BuildMessage( + string key, + string expectedType, + string actualType, + string? fullPath, + int? depth) + { + var sb = new StringBuilder(); + sb.Append("[PathExpansion] Conflict at '").Append(key).Append("': "); + sb.Append("expected ").Append(expectedType); + sb.Append(" but found ").Append(actualType); + + if (!string.IsNullOrEmpty(fullPath)) + { + sb.Append(" (in path '").Append(fullPath).Append("')"); + } + + if (depth is not null) + { + sb.Append(" (depth: ").Append(depth.Value).Append(")"); + } + + return sb.ToString(); + } + } +} diff --git a/tests/ToonFormat.SpecGenerator/Extensions/StringExtensions.cs b/tests/ToonFormat.SpecGenerator/Extensions/StringExtensions.cs index 9027500..75fece1 100644 --- a/tests/ToonFormat.SpecGenerator/Extensions/StringExtensions.cs +++ b/tests/ToonFormat.SpecGenerator/Extensions/StringExtensions.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace ToonFormat.SpecGenerator.Extensions; +namespace Toon.Format.SpecGenerator.Extensions; public static class StringExtensions { diff --git a/tests/ToonFormat.SpecGenerator/FixtureWriter.cs b/tests/ToonFormat.SpecGenerator/FixtureWriter.cs index a9068ec..43ad1fd 100644 --- a/tests/ToonFormat.SpecGenerator/FixtureWriter.cs +++ b/tests/ToonFormat.SpecGenerator/FixtureWriter.cs @@ -1,10 +1,10 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; -using ToonFormat.SpecGenerator.Extensions; -using ToonFormat.SpecGenerator.Types; +using Toon.Format.SpecGenerator.Types; +using Toon.Format.SpecGenerator.Extensions; -namespace ToonFormat.SpecGenerator; +namespace Toon.Format.SpecGenerator; internal class FixtureWriter(Fixtures fixture, string outputDir) where TTestCase : ITestCase @@ -21,6 +21,7 @@ public void WriteFile() Directory.CreateDirectory(OutputDir); using var writer = new StreamWriter(outputPath, false); + writer.NewLine = "\n"; // Use Unix line endings for cross-platform compatibility WriteHeader(writer); WriteLine(writer); @@ -92,7 +93,7 @@ private void WriteTestMethod(StreamWriter writer, TTestCase testCase) WriteLineIndented(writer, "var expected ="); WriteLine(writer, "\"\"\""); - Write(writer, encodeTestCase.Expected); + Write(writer, NormalizeLineEndings(encodeTestCase.Expected)); WriteLine(writer); WriteLine(writer, "\"\"\";"); @@ -102,7 +103,7 @@ private void WriteTestMethod(StreamWriter writer, TTestCase testCase) WriteLineIndented(writer, "var input ="); WriteLine(writer, "\"\"\""); - Write(writer, decodeTestCase.Input); + Write(writer, NormalizeLineEndings(decodeTestCase.Input)); WriteLine(writer); WriteLine(writer, "\"\"\";"); @@ -165,6 +166,10 @@ private void WriteTestMethod(StreamWriter writer, TTestCase testCase) WriteLineIndented(writer, $"Indent = {decodeTestCase.Options?.Indent ?? 2},"); WriteLineIndented(writer, $"Strict = {(decodeTestCase.Options?.Strict ?? true).ToString().ToLower()},"); + if (decodeTestCase.Options?.ExpandPaths != null) + WriteLineIndented(writer, $"ExpandPaths = {GetToonPathExpansionEnumFromString(decodeTestCase.Options.ExpandPaths)}"); + + Unindent(); WriteLineIndented(writer, "};"); @@ -173,13 +178,17 @@ private void WriteTestMethod(StreamWriter writer, TTestCase testCase) if (decodeTestCase.ShouldError) { + // Determine which exception type to expect based on the options + var isPathExpansionError = decodeTestCase.Options?.ExpandPaths != null; + var exceptionType = isPathExpansionError ? "ToonPathExpansionException" : "ToonFormatException"; + if (hasDecodeOptions) { - WriteLineIndented(writer, $"Assert.Throws(() => ToonDecoder.Decode(input, options));"); + WriteLineIndented(writer, $"Assert.Throws<{exceptionType}>(() => ToonDecoder.Decode(input, options));"); } else { - WriteLineIndented(writer, $"Assert.Throws(() => ToonDecoder.Decode(input));"); + WriteLineIndented(writer, $"Assert.Throws<{exceptionType}>(() => ToonDecoder.Decode(input));"); } } else @@ -272,6 +281,16 @@ private static string GetToonKeyFoldingEnumFromString(string? keyFoldingOption) }; } + private static string GetToonPathExpansionEnumFromString(string? expandPathsOption) + { + return expandPathsOption switch + { + "off" => "ToonPathExpansion.Off", + "safe" => "ToonPathExpansion.Safe", + _ => "ToonPathExpansion.Safe" + }; + } + private void WriteJsonNodeAsAnonymousType(StreamWriter writer, JsonNode node) { WriteJsonNode(writer, node); @@ -453,7 +472,7 @@ private void WriteUsings(StreamWriter writer) private void WriteNamespace(StreamWriter writer, string category) { - WriteLine(writer, $"namespace ToonFormat.Tests.{category.ToPascalCase()};"); + WriteLine(writer, $"namespace Toon.Format.Tests.{category.ToPascalCase()};"); } private void WriteLine(StreamWriter writer) @@ -470,4 +489,12 @@ private void Write(StreamWriter writer, string contents) { writer.Write(contents); } + + /// + /// Normalizes line endings to Unix format (LF) for cross-platform compatibility. + /// + private static string NormalizeLineEndings(string text) + { + return text.Replace("\r\n", "\n"); + } } diff --git a/tests/ToonFormat.SpecGenerator/Program.cs b/tests/ToonFormat.SpecGenerator/Program.cs index bd3cc60..8e55855 100644 --- a/tests/ToonFormat.SpecGenerator/Program.cs +++ b/tests/ToonFormat.SpecGenerator/Program.cs @@ -1,7 +1,7 @@ using CommandLine; using Microsoft.Extensions.Logging; -namespace ToonFormat.SpecGenerator; +namespace Toon.Format.SpecGenerator; public static class Program diff --git a/tests/ToonFormat.SpecGenerator/SpecGenerator.cs b/tests/ToonFormat.SpecGenerator/SpecGenerator.cs index eba64d0..23bc827 100644 --- a/tests/ToonFormat.SpecGenerator/SpecGenerator.cs +++ b/tests/ToonFormat.SpecGenerator/SpecGenerator.cs @@ -1,10 +1,10 @@ using Microsoft.Extensions.Logging; using System.Text.Json.Nodes; -using ToonFormat.SpecGenerator.Extensions; -using ToonFormat.SpecGenerator.Types; -using ToonFormat.SpecGenerator.Util; +using Toon.Format.SpecGenerator.Types; +using Toon.Format.SpecGenerator.Util; +using Toon.Format.SpecGenerator.Extensions; -namespace ToonFormat.SpecGenerator; +namespace Toon.Format.SpecGenerator; internal class SpecGenerator(ILogger logger) { @@ -16,6 +16,9 @@ public void GenerateSpecs(SpecGeneratorOptions options) try { + // Clean up test directory before generating new files + CleanTestDirectory(options.AbsoluteOutputPath); + logger.LogDebug("Cloning repository {RepoUrl} to {CloneDirectory}", options.SpecRepoUrl, toonSpecDir); GitTool.CloneRepository(options.SpecRepoUrl, toonSpecDir, @@ -52,6 +55,84 @@ public void GenerateSpecs(SpecGeneratorOptions options) logger.LogInformation("Spec generation completed."); } + private void CleanTestDirectory(string testDirectory) + { + if (!Directory.Exists(testDirectory)) + { + logger.LogDebug("Test directory {TestDirectory} does not exist, skipping cleanup", testDirectory); + return; + } + + logger.LogInformation("Cleaning test directory {TestDirectory}", testDirectory); + + // Delete all subdirectories + foreach (var dir in Directory.GetDirectories(testDirectory)) + { + try + { + Directory.Delete(dir, true); + logger.LogDebug("Deleted directory {Directory}", dir); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to delete directory {Directory}", dir); + } + } + + // Delete all files except .csproj files + foreach (var file in Directory.GetFiles(testDirectory)) + { + if (Path.GetExtension(file).Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("Preserving project file {File}", file); + continue; + } + + try + { + File.Delete(file); + logger.LogDebug("Deleted file {File}", file); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to delete file {File}", file); + } + } + + logger.LogInformation("Test directory cleanup completed"); + } + + private void CopyDirectory(string sourceDir, string destDir) + { + // Create destination directory if it doesn't exist + Directory.CreateDirectory(destDir); + + // Copy all files + foreach (var file in Directory.GetFiles(sourceDir)) + { + var fileName = Path.GetFileName(file); + var destFile = Path.Combine(destDir, fileName); + + try + { + File.Copy(file, destFile, overwrite: true); + logger.LogDebug("Copied file {SourceFile} to {DestFile}", file, destFile); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to copy file {SourceFile} to {DestFile}", file, destFile); + } + } + + // Copy all subdirectories recursively + foreach (var subDir in Directory.GetDirectories(sourceDir)) + { + var subDirName = Path.GetFileName(subDir); + var destSubDir = Path.Combine(destDir, subDirName); + CopyDirectory(subDir, destSubDir); + } + } + private void GenerateEncodeFixtures(string specDir, string outputDir, IEnumerable ignores) { var encodeFixtures = LoadEncodeFixtures(specDir); diff --git a/tests/ToonFormat.SpecGenerator/SpecGeneratorOptions.cs b/tests/ToonFormat.SpecGenerator/SpecGeneratorOptions.cs index d4e3e0c..5f8005e 100644 --- a/tests/ToonFormat.SpecGenerator/SpecGeneratorOptions.cs +++ b/tests/ToonFormat.SpecGenerator/SpecGeneratorOptions.cs @@ -1,6 +1,6 @@ using CommandLine; -namespace ToonFormat.SpecGenerator; +namespace Toon.Format.SpecGenerator; public class SpecGeneratorOptions { diff --git a/tests/ToonFormat.SpecGenerator/SpecSerializer.cs b/tests/ToonFormat.SpecGenerator/SpecSerializer.cs index d5a974b..a70db2d 100644 --- a/tests/ToonFormat.SpecGenerator/SpecSerializer.cs +++ b/tests/ToonFormat.SpecGenerator/SpecSerializer.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace ToonFormat.SpecGenerator; +namespace Toon.Format.SpecGenerator; internal static class SpecSerializer { diff --git a/tests/ToonFormat.SpecGenerator/ToonFormat.SpecGenerator.csproj b/tests/ToonFormat.SpecGenerator/ToonFormat.SpecGenerator.csproj index 123b6ae..bc3a504 100644 --- a/tests/ToonFormat.SpecGenerator/ToonFormat.SpecGenerator.csproj +++ b/tests/ToonFormat.SpecGenerator/ToonFormat.SpecGenerator.csproj @@ -1,10 +1,11 @@  - net9.0 + net10.0 enable enable Exe + Toon.Format.SpecGenerator @@ -13,4 +14,9 @@ + + + + + diff --git a/tests/ToonFormat.SpecGenerator/Types/DecodeTestCase.cs b/tests/ToonFormat.SpecGenerator/Types/DecodeTestCase.cs index 5a0a5c1..1899a09 100644 --- a/tests/ToonFormat.SpecGenerator/Types/DecodeTestCase.cs +++ b/tests/ToonFormat.SpecGenerator/Types/DecodeTestCase.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; -namespace ToonFormat.SpecGenerator.Types; +namespace Toon.Format.SpecGenerator.Types; public record DecodeTestCase : ITestCase { diff --git a/tests/ToonFormat.SpecGenerator/Types/EncodeTestCase.cs b/tests/ToonFormat.SpecGenerator/Types/EncodeTestCase.cs index a11b132..1935d88 100644 --- a/tests/ToonFormat.SpecGenerator/Types/EncodeTestCase.cs +++ b/tests/ToonFormat.SpecGenerator/Types/EncodeTestCase.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; -namespace ToonFormat.SpecGenerator.Types; +namespace Toon.Format.SpecGenerator.Types; public record EncodeTestCase : ITestCase { diff --git a/tests/ToonFormat.SpecGenerator/Types/Fixtures.cs b/tests/ToonFormat.SpecGenerator/Types/Fixtures.cs index c7cf9d7..205dab9 100644 --- a/tests/ToonFormat.SpecGenerator/Types/Fixtures.cs +++ b/tests/ToonFormat.SpecGenerator/Types/Fixtures.cs @@ -1,4 +1,4 @@ -namespace ToonFormat.SpecGenerator.Types; +namespace Toon.Format.SpecGenerator.Types; public record Fixtures where TTestCase : ITestCase diff --git a/tests/ToonFormat.SpecGenerator/Types/ITestCase.cs b/tests/ToonFormat.SpecGenerator/Types/ITestCase.cs index 1bcab07..6fff47d 100644 --- a/tests/ToonFormat.SpecGenerator/Types/ITestCase.cs +++ b/tests/ToonFormat.SpecGenerator/Types/ITestCase.cs @@ -1,4 +1,4 @@ -namespace ToonFormat.SpecGenerator.Types; +namespace Toon.Format.SpecGenerator.Types; public interface ITestCase { diff --git a/tests/ToonFormat.SpecGenerator/Types/TestCaseOptions.cs b/tests/ToonFormat.SpecGenerator/Types/TestCaseOptions.cs index e0ebf5d..7e4811d 100644 --- a/tests/ToonFormat.SpecGenerator/Types/TestCaseOptions.cs +++ b/tests/ToonFormat.SpecGenerator/Types/TestCaseOptions.cs @@ -1,4 +1,4 @@ -namespace ToonFormat.SpecGenerator.Types; +namespace Toon.Format.SpecGenerator.Types; public record TestCaseOptions { diff --git a/tests/ToonFormat.SpecGenerator/Util/GitTool.cs b/tests/ToonFormat.SpecGenerator/Util/GitTool.cs index 401c551..6481694 100644 --- a/tests/ToonFormat.SpecGenerator/Util/GitTool.cs +++ b/tests/ToonFormat.SpecGenerator/Util/GitTool.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace ToonFormat.SpecGenerator.Util; +namespace Toon.Format.SpecGenerator.Util; internal static class GitTool { @@ -23,6 +23,22 @@ public static void CloneRepository(string repositoryUrl, string destinationPath, process.Start(); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + var errorMessage = $"Git clone failed with exit code {process.ExitCode}"; + if (!string.IsNullOrWhiteSpace(error)) + { + errorMessage += $": {error}"; + } + logger?.LogError("{ErrorMessage}", errorMessage); + throw new InvalidOperationException(errorMessage); + } + + logger?.LogDebug("Git clone output: {Output}", output); } } \ No newline at end of file diff --git a/tests/ToonFormat.Tests/Decode/ArraysNested.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/ArraysNested.cs similarity index 79% rename from tests/ToonFormat.Tests/Decode/ArraysNested.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/ArraysNested.cs index 39951c6..30c1934 100644 --- a/tests/ToonFormat.Tests/Decode/ArraysNested.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/ArraysNested.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] @@ -116,16 +116,16 @@ public void ParsesListArraysContainingObjectsWithNestedProperties() } [Fact] - [Trait("Description", "parses nested tabular arrays as first field on hyphen line")] - public void ParsesNestedTabularArraysAsFirstFieldOnHyphenLine() + [Trait("Description", "parses list items whose first field is a tabular array")] + public void ParsesListItemsWhoseFirstFieldIsATabularArray() { // Arrange var input = """ items[1]: - users[2]{id,name}: - 1,Ada - 2,Bob + 1,Ada + 2,Bob status: active """; @@ -139,6 +139,29 @@ public void ParsesNestedTabularArraysAsFirstFieldOnHyphenLine() Assert.True(JsonNode.DeepEquals(result, expected)); } + [Fact] + [Trait("Description", "parses single-field list-item object with tabular array")] + public void ParsesSingleFieldListItemObjectWithTabularArray() + { + // Arrange + var input = +""" +items[1]: + - users[2]{id,name}: + 1,Ada + 2,Bob +"""; + + // Act & Assert + var result = ToonDecoder.Decode(input); + + var expected = JsonNode.Parse(""" +{"items":[{"users":[{"id":1,"name":"Ada"},{"id":2,"name":"Bob"}]}]} +"""); + + Assert.True(JsonNode.DeepEquals(result, expected)); + } + [Fact] [Trait("Description", "parses objects containing arrays (including empty arrays) in list format")] public void ParsesObjectsContainingArraysIncludingEmptyArraysInListFormat() @@ -147,7 +170,7 @@ public void ParsesObjectsContainingArraysIncludingEmptyArraysInListFormat() var input = """ items[1]: - - name: test + - name: Ada data[0]: """; @@ -155,7 +178,7 @@ public void ParsesObjectsContainingArraysIncludingEmptyArraysInListFormat() var result = ToonDecoder.Decode(input); var expected = JsonNode.Parse(""" -{"items":[{"name":"test","data":[]}]} +{"items":[{"name":"Ada","data":[]}]} """); Assert.True(JsonNode.DeepEquals(result, expected)); @@ -170,8 +193,8 @@ public void ParsesArraysOfArraysWithinObjects() """ items[1]: - matrix[2]: - - [2]: 1,2 - - [2]: 3,4 + - [2]: 1,2 + - [2]: 3,4 name: grid """; @@ -274,8 +297,8 @@ public void ParsesMixedLengthInnerArrays() } [Fact] - [Trait("Description", "parses root arrays of primitives (inline)")] - public void ParsesRootArraysOfPrimitivesInline() + [Trait("Description", "parses root-level primitive array inline")] + public void ParsesRootLevelPrimitiveArrayInline() { // Arrange var input = @@ -294,8 +317,8 @@ public void ParsesRootArraysOfPrimitivesInline() } [Fact] - [Trait("Description", "parses root arrays of uniform objects in tabular format")] - public void ParsesRootArraysOfUniformObjectsInTabularFormat() + [Trait("Description", "parses root-level array of uniform objects in tabular format")] + public void ParsesRootLevelArrayOfUniformObjectsInTabularFormat() { // Arrange var input = @@ -316,8 +339,8 @@ public void ParsesRootArraysOfUniformObjectsInTabularFormat() } [Fact] - [Trait("Description", "parses root arrays of non-uniform objects in list format")] - public void ParsesRootArraysOfNonUniformObjectsInListFormat() + [Trait("Description", "parses root-level array of non-uniform objects in list format")] + public void ParsesRootLevelArrayOfNonUniformObjectsInListFormat() { // Arrange var input = @@ -339,28 +362,34 @@ public void ParsesRootArraysOfNonUniformObjectsInListFormat() } [Fact] - [Trait("Description", "parses empty root arrays")] - public void ParsesEmptyRootArrays() + [Trait("Description", "parses root-level array mixing primitive, object, and array of objects in list format")] + public void ParsesRootLevelArrayMixingPrimitiveObjectAndArrayOfObjectsInListFormat() { // Arrange var input = """ -[0]: +[3]: + - summary + - id: 1 + name: Ada + - [2]: + - id: 2 + - status: draft """; // Act & Assert var result = ToonDecoder.Decode(input); var expected = JsonNode.Parse(""" -[] +["summary",{"id":1,"name":"Ada"},[{"id":2},{"status":"draft"}]] """); Assert.True(JsonNode.DeepEquals(result, expected)); } [Fact] - [Trait("Description", "parses root arrays of arrays")] - public void ParsesRootArraysOfArrays() + [Trait("Description", "parses root-level array of arrays")] + public void ParsesRootLevelArrayOfArrays() { // Arrange var input = @@ -380,6 +409,26 @@ public void ParsesRootArraysOfArrays() Assert.True(JsonNode.DeepEquals(result, expected)); } + [Fact] + [Trait("Description", "parses empty root-level array")] + public void ParsesEmptyRootLevelArray() + { + // Arrange + var input = +""" +[0]: +"""; + + // Act & Assert + var result = ToonDecoder.Decode(input); + + var expected = JsonNode.Parse(""" +[] +"""); + + Assert.True(JsonNode.DeepEquals(result, expected)); + } + [Fact] [Trait("Description", "parses complex mixed object with arrays and nested objects")] public void ParsesComplexMixedObjectWithArraysAndNestedObjects() @@ -406,8 +455,8 @@ public void ParsesComplexMixedObjectWithArraysAndNestedObjects() } [Fact] - [Trait("Description", "parses arrays mixing primitives, objects and strings (list format)")] - public void ParsesArraysMixingPrimitivesObjectsAndStringsListFormat() + [Trait("Description", "parses arrays mixing primitives, objects, and strings in list format")] + public void ParsesArraysMixingPrimitivesObjectsAndStringsInListFormat() { // Arrange var input = diff --git a/tests/ToonFormat.Tests/Decode/ArraysPrimitive.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/ArraysPrimitive.cs similarity index 99% rename from tests/ToonFormat.Tests/Decode/ArraysPrimitive.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/ArraysPrimitive.cs index 8a2d574..889aa16 100644 --- a/tests/ToonFormat.Tests/Decode/ArraysPrimitive.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/ArraysPrimitive.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] diff --git a/tests/ToonFormat.Tests/Decode/ArraysTabular.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/ArraysTabular.cs similarity index 93% rename from tests/ToonFormat.Tests/Decode/ArraysTabular.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/ArraysTabular.cs index 98d809a..2c7781b 100644 --- a/tests/ToonFormat.Tests/Decode/ArraysTabular.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/ArraysTabular.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] @@ -131,8 +131,8 @@ public void ParsesQuotedKeyWithTabularArrayFormat() } [Fact] - [Trait("Description", "unquoted colon terminates tabular rows and starts key-value pair")] - public void UnquotedColonTerminatesTabularRowsAndStartsKeyValuePair() + [Trait("Description", "treats unquoted colon as terminator for tabular rows and start of key-value pair")] + public void TreatsUnquotedColonAsTerminatorForTabularRowsAndStartOfKeyValuePair() { // Arrange var input = diff --git a/tests/ToonFormat.Tests/Decode/BlankLines.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/BlankLines.cs similarity index 99% rename from tests/ToonFormat.Tests/Decode/BlankLines.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/BlankLines.cs index c845dfb..ccc41a5 100644 --- a/tests/ToonFormat.Tests/Decode/BlankLines.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/BlankLines.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] diff --git a/tests/ToonFormat.Tests/Decode/Delimiters.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/Delimiters.cs similarity index 92% rename from tests/ToonFormat.Tests/Decode/Delimiters.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/Delimiters.cs index 2f1a576..28ec15f 100644 --- a/tests/ToonFormat.Tests/Decode/Delimiters.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/Delimiters.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] @@ -169,8 +169,8 @@ public void ParsesNestedArraysWithPipeDelimiter() } [Fact] - [Trait("Description", "nested arrays inside list items default to comma delimiter")] - public void NestedArraysInsideListItemsDefaultToCommaDelimiter() + [Trait("Description", "parses nested arrays inside list items with default comma delimiter")] + public void ParsesNestedArraysInsideListItemsWithDefaultCommaDelimiter() { // Arrange var input = @@ -190,8 +190,8 @@ public void NestedArraysInsideListItemsDefaultToCommaDelimiter() } [Fact] - [Trait("Description", "nested arrays inside list items default to comma with pipe parent")] - public void NestedArraysInsideListItemsDefaultToCommaWithPipeParent() + [Trait("Description", "parses nested arrays inside list items with default comma delimiter when parent uses pipe")] + public void ParsesNestedArraysInsideListItemsWithDefaultCommaDelimiterWhenParentUsesPipe() { // Arrange var input = @@ -211,8 +211,8 @@ public void NestedArraysInsideListItemsDefaultToCommaWithPipeParent() } [Fact] - [Trait("Description", "parses root arrays with tab delimiter")] - public void ParsesRootArraysWithTabDelimiter() + [Trait("Description", "parses root-level array with tab delimiter")] + public void ParsesRootLevelArrayWithTabDelimiter() { // Arrange var input = @@ -231,8 +231,8 @@ public void ParsesRootArraysWithTabDelimiter() } [Fact] - [Trait("Description", "parses root arrays with pipe delimiter")] - public void ParsesRootArraysWithPipeDelimiter() + [Trait("Description", "parses root-level array with pipe delimiter")] + public void ParsesRootLevelArrayWithPipeDelimiter() { // Arrange var input = @@ -251,8 +251,8 @@ public void ParsesRootArraysWithPipeDelimiter() } [Fact] - [Trait("Description", "parses root arrays of objects with tab delimiter")] - public void ParsesRootArraysOfObjectsWithTabDelimiter() + [Trait("Description", "parses root-level array of objects with tab delimiter")] + public void ParsesRootLevelArrayOfObjectsWithTabDelimiter() { // Arrange var input = @@ -273,8 +273,8 @@ public void ParsesRootArraysOfObjectsWithTabDelimiter() } [Fact] - [Trait("Description", "parses root arrays of objects with pipe delimiter")] - public void ParsesRootArraysOfObjectsWithPipeDelimiter() + [Trait("Description", "parses root-level array of objects with pipe delimiter")] + public void ParsesRootLevelArrayOfObjectsWithPipeDelimiter() { // Arrange var input = diff --git a/tests/ToonFormat.Tests/Decode/IndentationErrors.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/IndentationErrors.cs similarity index 83% rename from tests/ToonFormat.Tests/Decode/IndentationErrors.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/IndentationErrors.cs index b305875..bdc171a 100644 --- a/tests/ToonFormat.Tests/Decode/IndentationErrors.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/IndentationErrors.cs @@ -14,15 +14,15 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] public class IndentationErrors { [Fact] - [Trait("Description", "throws when object field has non-multiple indentation (3 spaces with indent=2)")] - public void ThrowsWhenObjectFieldHasNonMultipleIndentation3SpacesWithIndent2() + [Trait("Description", "throws on object field with non-multiple indentation (3 spaces with indent=2)")] + public void ThrowsOnObjectFieldWithNonMultipleIndentation3SpacesWithIndent2() { // Arrange var input = @@ -42,8 +42,8 @@ public void ThrowsWhenObjectFieldHasNonMultipleIndentation3SpacesWithIndent2() } [Fact] - [Trait("Description", "throws when list item has non-multiple indentation (3 spaces with indent=2)")] - public void ThrowsWhenListItemHasNonMultipleIndentation3SpacesWithIndent2() + [Trait("Description", "throws on list item with non-multiple indentation (3 spaces with indent=2)")] + public void ThrowsOnListItemWithNonMultipleIndentation3SpacesWithIndent2() { // Arrange var input = @@ -64,8 +64,8 @@ public void ThrowsWhenListItemHasNonMultipleIndentation3SpacesWithIndent2() } [Fact] - [Trait("Description", "throws with custom indent size when non-multiple (3 spaces with indent=4)")] - public void ThrowsWithCustomIndentSizeWhenNonMultiple3SpacesWithIndent4() + [Trait("Description", "throws on non-multiple indentation with custom indent=4 (3 spaces)")] + public void ThrowsOnNonMultipleIndentationWithCustomIndent43Spaces() { // Arrange var input = @@ -112,8 +112,8 @@ public void AcceptsCorrectIndentationWithCustomIndentSize4SpacesWithIndent4() } [Fact] - [Trait("Description", "throws when tab character used in indentation")] - public void ThrowsWhenTabCharacterUsedInIndentation() + [Trait("Description", "throws on tab character used in indentation")] + public void ThrowsOnTabCharacterUsedInIndentation() { // Arrange var input = @@ -133,8 +133,8 @@ public void ThrowsWhenTabCharacterUsedInIndentation() } [Fact] - [Trait("Description", "throws when mixed tabs and spaces in indentation")] - public void ThrowsWhenMixedTabsAndSpacesInIndentation() + [Trait("Description", "throws on mixed tabs and spaces in indentation")] + public void ThrowsOnMixedTabsAndSpacesInIndentation() { // Arrange var input = @@ -154,8 +154,8 @@ public void ThrowsWhenMixedTabsAndSpacesInIndentation() } [Fact] - [Trait("Description", "throws when tab at start of line")] - public void ThrowsWhenTabAtStartOfLine() + [Trait("Description", "throws on tab at start of line")] + public void ThrowsOnTabAtStartOfLine() { // Arrange var input = @@ -307,8 +307,8 @@ public void AcceptsDeeplyNestedNonMultiplesWhenStrictFalse() } [Fact] - [Trait("Description", "empty lines do not trigger validation errors")] - public void EmptyLinesDoNotTriggerValidationErrors() + [Trait("Description", "parses empty lines without validation errors")] + public void ParsesEmptyLinesWithoutValidationErrors() { // Arrange var input = @@ -335,8 +335,8 @@ public void EmptyLinesDoNotTriggerValidationErrors() } [Fact] - [Trait("Description", "root-level content (0 indentation) is always valid")] - public void RootLevelContent0IndentationIsAlwaysValid() + [Trait("Description", "parses root-level content (0 indentation) as always valid")] + public void ParsesRootLevelContent0IndentationAsAlwaysValid() { // Arrange var input = @@ -363,8 +363,8 @@ public void RootLevelContent0IndentationIsAlwaysValid() } [Fact] - [Trait("Description", "lines with only spaces are not validated if empty")] - public void LinesWithOnlySpacesAreNotValidatedIfEmpty() + [Trait("Description", "parses lines with only spaces without validation if empty")] + public void ParsesLinesWithOnlySpacesWithoutValidationIfEmpty() { // Arrange var input = diff --git a/tests/ToonFormat.Tests/Decode/Numbers.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/Numbers.cs similarity index 99% rename from tests/ToonFormat.Tests/Decode/Numbers.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/Numbers.cs index 15ccdcb..c76b9f6 100644 --- a/tests/ToonFormat.Tests/Decode/Numbers.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/Numbers.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] diff --git a/tests/ToonFormat.Tests/Decode/Objects.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/Objects.cs similarity index 99% rename from tests/ToonFormat.Tests/Decode/Objects.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/Objects.cs index c156aa3..56e1661 100644 --- a/tests/ToonFormat.Tests/Decode/Objects.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/Objects.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] diff --git a/tests/ToonFormat.Tests/Decode/PathExpansion.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/PathExpansion.cs similarity index 89% rename from tests/ToonFormat.Tests/Decode/PathExpansion.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/PathExpansion.cs index 4c9f580..e560497 100644 --- a/tests/ToonFormat.Tests/Decode/PathExpansion.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/PathExpansion.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] @@ -35,6 +35,7 @@ public void ExpandsDottedKeyToNestedObjectInSafeMode() { Indent = 2, Strict = true, + ExpandPaths = ToonPathExpansion.Safe }; var result = ToonDecoder.Decode(input, options); @@ -61,6 +62,7 @@ public void ExpandsDottedKeyWithInlineArray() { Indent = 2, Strict = true, + ExpandPaths = ToonPathExpansion.Safe }; var result = ToonDecoder.Decode(input, options); @@ -89,6 +91,7 @@ public void ExpandsDottedKeyWithTabularArray() { Indent = 2, Strict = true, + ExpandPaths = ToonPathExpansion.Safe }; var result = ToonDecoder.Decode(input, options); @@ -115,6 +118,7 @@ public void PreservesLiteralDottedKeysWhenExpansionIsOff() { Indent = 2, Strict = true, + ExpandPaths = ToonPathExpansion.Off }; var result = ToonDecoder.Decode(input, options); @@ -143,6 +147,7 @@ public void ExpandsAndDeepMergesPreservingDocumentOrderInsertion() { Indent = 2, Strict = true, + ExpandPaths = ToonPathExpansion.Safe }; var result = ToonDecoder.Decode(input, options); @@ -170,9 +175,10 @@ public void ThrowsOnExpansionConflictObjectVsPrimitiveWhenStrictTrue() { Indent = 2, Strict = true, + ExpandPaths = ToonPathExpansion.Safe }; - Assert.Throws(() => ToonDecoder.Decode(input, options)); + Assert.Throws(() => ToonDecoder.Decode(input, options)); } [Fact] @@ -191,9 +197,10 @@ public void ThrowsOnExpansionConflictObjectVsArrayWhenStrictTrue() { Indent = 2, Strict = true, + ExpandPaths = ToonPathExpansion.Safe }; - Assert.Throws(() => ToonDecoder.Decode(input, options)); + Assert.Throws(() => ToonDecoder.Decode(input, options)); } [Fact] @@ -212,6 +219,7 @@ public void AppliesLwwWhenStrictFalsePrimitiveOverwritesExpandedObject() { Indent = 2, Strict = false, + ExpandPaths = ToonPathExpansion.Safe }; var result = ToonDecoder.Decode(input, options); @@ -239,6 +247,7 @@ public void AppliesLwwWhenStrictFalseExpandedObjectOverwritesPrimitive() { Indent = 2, Strict = false, + ExpandPaths = ToonPathExpansion.Safe }; var result = ToonDecoder.Decode(input, options); @@ -266,6 +275,7 @@ public void PreservesQuotedDottedKeyAsLiteralWhenExpandpathsSafe() { Indent = 2, Strict = true, + ExpandPaths = ToonPathExpansion.Safe }; var result = ToonDecoder.Decode(input, options); @@ -292,6 +302,7 @@ public void PreservesNonIdentifiersegmentKeysAsLiterals() { Indent = 2, Strict = true, + ExpandPaths = ToonPathExpansion.Safe }; var result = ToonDecoder.Decode(input, options); @@ -318,6 +329,7 @@ public void ExpandsKeysCreatingEmptyNestedObjects() { Indent = 2, Strict = true, + ExpandPaths = ToonPathExpansion.Safe }; var result = ToonDecoder.Decode(input, options); diff --git a/tests/ToonFormat.Tests/Decode/Primitives.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/Primitives.cs similarity index 99% rename from tests/ToonFormat.Tests/Decode/Primitives.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/Primitives.cs index fb80c5f..d541a16 100644 --- a/tests/ToonFormat.Tests/Decode/Primitives.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/Primitives.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] diff --git a/tests/ToonFormat.Tests/Decode/RootForm.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/RootForm.cs similarity index 83% rename from tests/ToonFormat.Tests/Decode/RootForm.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/RootForm.cs index 08e8a01..adc4efc 100644 --- a/tests/ToonFormat.Tests/Decode/RootForm.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/RootForm.cs @@ -14,15 +14,15 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] public class RootForm { [Fact] - [Trait("Description", "empty document decodes to empty object")] - public void EmptyDocumentDecodesToEmptyObject() + [Trait("Description", "parses empty document as empty object")] + public void ParsesEmptyDocumentAsEmptyObject() { // Arrange var input = diff --git a/tests/ToonFormat.Tests/Decode/ValidationErrors.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/ValidationErrors.cs similarity index 91% rename from tests/ToonFormat.Tests/Decode/ValidationErrors.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/ValidationErrors.cs index 94298ba..1e9c836 100644 --- a/tests/ToonFormat.Tests/Decode/ValidationErrors.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/ValidationErrors.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] @@ -51,8 +51,8 @@ public void ThrowsOnArrayLengthMismatchListFormatTooMany() } [Fact] - [Trait("Description", "throws when tabular row value count does not match header field count")] - public void ThrowsWhenTabularRowValueCountDoesNotMatchHeaderFieldCount() + [Trait("Description", "throws on tabular row value count mismatch with header field count")] + public void ThrowsOnTabularRowValueCountMismatchWithHeaderFieldCount() { // Arrange var input = @@ -67,8 +67,8 @@ public void ThrowsWhenTabularRowValueCountDoesNotMatchHeaderFieldCount() } [Fact] - [Trait("Description", "throws when tabular row count does not match header length")] - public void ThrowsWhenTabularRowCountDoesNotMatchHeaderLength() + [Trait("Description", "throws on tabular row count mismatch with header length")] + public void ThrowsOnTabularRowCountMismatchWithHeaderLength() { // Arrange var input = diff --git a/tests/ToonFormat.Tests/Decode/Whitespace.cs b/tests/ToonFormat.Tests/GeneratedTests/Decode/Whitespace.cs similarity index 95% rename from tests/ToonFormat.Tests/Decode/Whitespace.cs rename to tests/ToonFormat.Tests/GeneratedTests/Decode/Whitespace.cs index 676a4f7..3b62570 100644 --- a/tests/ToonFormat.Tests/Decode/Whitespace.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Decode/Whitespace.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Decode; +namespace Toon.Format.Tests.Decode; [Trait("Category", "decode")] @@ -123,8 +123,8 @@ public void ToleratesSpacesAroundDelimitersWithQuotedValues() } [Fact] - [Trait("Description", "empty tokens decode to empty string")] - public void EmptyTokensDecodeToEmptyString() + [Trait("Description", "parses empty tokens as empty string")] + public void ParsesEmptyTokensAsEmptyString() { // Arrange var input = diff --git a/tests/ToonFormat.Tests/Encode/ArraysNested.cs b/tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysNested.cs similarity index 88% rename from tests/ToonFormat.Tests/Encode/ArraysNested.cs rename to tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysNested.cs index 6f58f8e..c9a2a8c 100644 --- a/tests/ToonFormat.Tests/Encode/ArraysNested.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysNested.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Encode; +namespace Toon.Format.Tests.Encode; [Trait("Category", "encode")] @@ -259,18 +259,44 @@ public void EncodesRootLevelArrayOfNonUniformObjectsInListFormat() } [Fact] - [Trait("Description", "encodes empty root-level array")] - public void EncodesEmptyRootLevelArray() + [Trait("Description", "encodes root-level array mixing primitive, object, and array of objects in list format")] + public void EncodesRootLevelArrayMixingPrimitiveObjectAndArrayOfObjectsInListFormat() { // Arrange var input = new object[] { + @"summary", + new + { + @id = 1, + @name = @"Ada", + } + , + new object[] { + new + { + @id = 2, + } + , + new + { + @status = @"draft", + } + , + } + , } ; var expected = """ -[0]: +[3]: + - summary + - id: 1 + name: Ada + - [2]: + - id: 2 + - status: draft """; // Act & Assert @@ -310,6 +336,27 @@ public void EncodesRootLevelArraysOfArrays() Assert.Equal(expected, result); } + [Fact] + [Trait("Description", "encodes empty root-level array")] + public void EncodesEmptyRootLevelArray() + { + // Arrange + var input = + new object[] { + } + ; + + var expected = +""" +[0]: +"""; + + // Act & Assert + var result = ToonEncoder.Encode(input); + + Assert.Equal(expected, result); + } + [Fact] [Trait("Description", "encodes complex nested structure")] public void EncodesComplexNestedStructure() diff --git a/tests/ToonFormat.Tests/Encode/ArraysObjects.cs b/tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysObjects.cs similarity index 84% rename from tests/ToonFormat.Tests/Encode/ArraysObjects.cs rename to tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysObjects.cs index 50aadf6..440c511 100644 --- a/tests/ToonFormat.Tests/Encode/ArraysObjects.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysObjects.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Encode; +namespace Toon.Format.Tests.Encode; [Trait("Category", "encode")] @@ -119,7 +119,7 @@ public void PreservesFieldOrderInListItemsArrayFirst() 3, } , - @name = @"test", + @name = @"Ada", } , } @@ -131,7 +131,7 @@ public void PreservesFieldOrderInListItemsArrayFirst() """ items[1]: - nums[3]: 1,2,3 - name: test + name: Ada """; // Act & Assert @@ -151,7 +151,7 @@ public void PreservesFieldOrderInListItemsPrimitiveFirst() @items =new object[] { new { - @name = @"test", + @name = @"Ada", @nums =new object[] { 1, 2, @@ -168,7 +168,7 @@ public void PreservesFieldOrderInListItemsPrimitiveFirst() var expected = """ items[1]: - - name: test + - name: Ada nums[3]: 1,2,3 """; @@ -214,8 +214,8 @@ public void UsesListFormatForObjectsContainingArraysOfArrays() """ items[1]: - matrix[2]: - - [2]: 1,2 - - [2]: 3,4 + - [2]: 1,2 + - [2]: 3,4 name: grid """; @@ -263,8 +263,8 @@ public void UsesTabularFormatForNestedUniformObjectArrays() """ items[1]: - users[2]{id,name}: - 1,Ada - 2,Bob + 1,Ada + 2,Bob status: active """; @@ -311,9 +311,9 @@ public void UsesListFormatForNestedObjectArraysWithMismatchedKeys() """ items[1]: - users[2]: - - id: 1 - name: Ada - - id: 2 + - id: 1 + name: Ada + - id: 2 status: active """; @@ -419,7 +419,7 @@ public void EncodesObjectsWithEmptyArraysInListFormat() @items =new object[] { new { - @name = @"test", + @name = @"Ada", @data =new object[] { } , @@ -433,7 +433,7 @@ public void EncodesObjectsWithEmptyArraysInListFormat() var expected = """ items[1]: - - name: test + - name: Ada data[0]: """; @@ -444,8 +444,8 @@ public void EncodesObjectsWithEmptyArraysInListFormat() } [Fact] - [Trait("Description", "places first field of nested tabular arrays on hyphen line")] - public void PlacesFirstFieldOfNestedTabularArraysOnHyphenLine() + [Trait("Description", "uses canonical encoding for multi-field list-item objects with tabular arrays")] + public void UsesCanonicalEncodingForMultiFieldListItemObjectsWithTabularArrays() { // Arrange var input = @@ -479,8 +479,8 @@ public void PlacesFirstFieldOfNestedTabularArraysOnHyphenLine() """ items[1]: - users[2]{id}: - 1 - 2 + 1 + 2 note: x """; @@ -490,6 +490,53 @@ public void PlacesFirstFieldOfNestedTabularArraysOnHyphenLine() Assert.Equal(expected, result); } + [Fact] + [Trait("Description", "uses canonical encoding for single-field list-item tabular arrays")] + public void UsesCanonicalEncodingForSingleFieldListItemTabularArrays() + { + // Arrange + var input = + new + { + @items =new object[] { + new + { + @users =new object[] { + new + { + @id = 1, + @name = @"Ada", + } + , + new + { + @id = 2, + @name = @"Bob", + } + , + } +, + } + , + } +, + } + ; + + var expected = +""" +items[1]: + - users[2]{id,name}: + 1,Ada + 2,Bob +"""; + + // Act & Assert + var result = ToonEncoder.Encode(input); + + Assert.Equal(expected, result); + } + [Fact] [Trait("Description", "places empty arrays on hyphen line when first")] public void PlacesEmptyArraysOnHyphenLineWhenFirst() @@ -525,6 +572,40 @@ public void PlacesEmptyArraysOnHyphenLineWhenFirst() Assert.Equal(expected, result); } + [Fact] + [Trait("Description", "encodes empty object list items as bare hyphen")] + public void EncodesEmptyObjectListItemsAsBareHyphen() + { + // Arrange + var input = + new + { + @items =new object[] { + @"first", + @"second", + new + { + } + , + } +, + } + ; + + var expected = +""" +items[3]: + - first + - second + - +"""; + + // Act & Assert + var result = ToonEncoder.Encode(input); + + Assert.Equal(expected, result); + } + [Fact] [Trait("Description", "uses field order from first object for tabular headers")] public void UsesFieldOrderFromFirstObjectForTabularHeaders() @@ -567,8 +648,8 @@ public void UsesFieldOrderFromFirstObjectForTabularHeaders() } [Fact] - [Trait("Description", "uses list format when one object has nested column")] - public void UsesListFormatWhenOneObjectHasNestedColumn() + [Trait("Description", "uses list format when one object has nested field")] + public void UsesListFormatWhenOneObjectHasNestedField() { // Arrange var input = diff --git a/tests/ToonFormat.Tests/Encode/ArraysPrimitive.cs b/tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysPrimitive.cs similarity index 99% rename from tests/ToonFormat.Tests/Encode/ArraysPrimitive.cs rename to tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysPrimitive.cs index add5fc7..1bdc5d9 100644 --- a/tests/ToonFormat.Tests/Encode/ArraysPrimitive.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysPrimitive.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Encode; +namespace Toon.Format.Tests.Encode; [Trait("Category", "encode")] diff --git a/tests/ToonFormat.Tests/Encode/ArraysTabular.cs b/tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysTabular.cs similarity index 95% rename from tests/ToonFormat.Tests/Encode/ArraysTabular.cs rename to tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysTabular.cs index 4241130..3fd3b97 100644 --- a/tests/ToonFormat.Tests/Encode/ArraysTabular.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Encode/ArraysTabular.cs @@ -14,15 +14,15 @@ using Xunit; -namespace ToonFormat.Tests.Encode; +namespace Toon.Format.Tests.Encode; [Trait("Category", "encode")] public class ArraysTabular { [Fact] - [Trait("Description", "encodes arrays of similar objects in tabular format")] - public void EncodesArraysOfSimilarObjectsInTabularFormat() + [Trait("Description", "encodes arrays of uniform objects in tabular format")] + public void EncodesArraysOfUniformObjectsInTabularFormat() { // Arrange var input = diff --git a/tests/ToonFormat.Tests/Encode/Delimiters.cs b/tests/ToonFormat.Tests/GeneratedTests/Encode/Delimiters.cs similarity index 96% rename from tests/ToonFormat.Tests/Encode/Delimiters.cs rename to tests/ToonFormat.Tests/GeneratedTests/Encode/Delimiters.cs index fab7dea..5ffb29f 100644 --- a/tests/ToonFormat.Tests/Encode/Delimiters.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Encode/Delimiters.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Encode; +namespace Toon.Format.Tests.Encode; [Trait("Category", "encode")] @@ -296,8 +296,8 @@ public void EncodesNestedArraysWithPipeDelimiter() } [Fact] - [Trait("Description", "encodes root arrays with tab delimiter")] - public void EncodesRootArraysWithTabDelimiter() + [Trait("Description", "encodes root-level array with tab delimiter")] + public void EncodesRootLevelArrayWithTabDelimiter() { // Arrange var input = @@ -325,8 +325,8 @@ public void EncodesRootArraysWithTabDelimiter() } [Fact] - [Trait("Description", "encodes root arrays with pipe delimiter")] - public void EncodesRootArraysWithPipeDelimiter() + [Trait("Description", "encodes root-level array with pipe delimiter")] + public void EncodesRootLevelArrayWithPipeDelimiter() { // Arrange var input = @@ -354,8 +354,8 @@ public void EncodesRootArraysWithPipeDelimiter() } [Fact] - [Trait("Description", "encodes root arrays of objects with tab delimiter")] - public void EncodesRootArraysOfObjectsWithTabDelimiter() + [Trait("Description", "encodes root-level array of objects with tab delimiter")] + public void EncodesRootLevelArrayOfObjectsWithTabDelimiter() { // Arrange var input = @@ -392,8 +392,8 @@ public void EncodesRootArraysOfObjectsWithTabDelimiter() } [Fact] - [Trait("Description", "encodes root arrays of objects with pipe delimiter")] - public void EncodesRootArraysOfObjectsWithPipeDelimiter() + [Trait("Description", "encodes root-level array of objects with pipe delimiter")] + public void EncodesRootLevelArrayOfObjectsWithPipeDelimiter() { // Arrange var input = diff --git a/tests/ToonFormat.Tests/Encode/KeyFolding.cs b/tests/ToonFormat.Tests/GeneratedTests/Encode/KeyFolding.cs similarity index 99% rename from tests/ToonFormat.Tests/Encode/KeyFolding.cs rename to tests/ToonFormat.Tests/GeneratedTests/Encode/KeyFolding.cs index 81f47fe..44166c7 100644 --- a/tests/ToonFormat.Tests/Encode/KeyFolding.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Encode/KeyFolding.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Encode; +namespace Toon.Format.Tests.Encode; [Trait("Category", "encode")] diff --git a/tests/ToonFormat.Tests/Encode/Objects.cs b/tests/ToonFormat.Tests/GeneratedTests/Encode/Objects.cs similarity index 99% rename from tests/ToonFormat.Tests/Encode/Objects.cs rename to tests/ToonFormat.Tests/GeneratedTests/Encode/Objects.cs index 2ff8277..5d29303 100644 --- a/tests/ToonFormat.Tests/Encode/Objects.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Encode/Objects.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Encode; +namespace Toon.Format.Tests.Encode; [Trait("Category", "encode")] diff --git a/tests/ToonFormat.Tests/Encode/Primitives.cs b/tests/ToonFormat.Tests/GeneratedTests/Encode/Primitives.cs similarity index 99% rename from tests/ToonFormat.Tests/Encode/Primitives.cs rename to tests/ToonFormat.Tests/GeneratedTests/Encode/Primitives.cs index f01c217..b302006 100644 --- a/tests/ToonFormat.Tests/Encode/Primitives.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Encode/Primitives.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Encode; +namespace Toon.Format.Tests.Encode; [Trait("Category", "encode")] diff --git a/tests/ToonFormat.Tests/Encode/Whitespace.cs b/tests/ToonFormat.Tests/GeneratedTests/Encode/Whitespace.cs similarity index 98% rename from tests/ToonFormat.Tests/Encode/Whitespace.cs rename to tests/ToonFormat.Tests/GeneratedTests/Encode/Whitespace.cs index e64b71a..960c269 100644 --- a/tests/ToonFormat.Tests/Encode/Whitespace.cs +++ b/tests/ToonFormat.Tests/GeneratedTests/Encode/Whitespace.cs @@ -14,7 +14,7 @@ using Xunit; -namespace ToonFormat.Tests.Encode; +namespace Toon.Format.Tests.Encode; [Trait("Category", "encode")] diff --git a/tests/ToonFormat.Tests/ManualTests/Encode/ArraysObjectsManual.cs b/tests/ToonFormat.Tests/ManualTests/Encode/ArraysObjectsManual.cs new file mode 100644 index 0000000..256e1cc --- /dev/null +++ b/tests/ToonFormat.Tests/ManualTests/Encode/ArraysObjectsManual.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Toon.Format; +using Xunit; + +namespace Toon.Format.Tests.Encode; + +[Trait("Category", "encode")] +public class ArraysObjectsManual +{ + [Fact] + [Trait("Description", "tabular array with multiple sibling fields")] + public void TabularArrayWithMultipleSiblingFields() + { + // Arrange + var input = + new + { + @items = new object[] { + new + { + @users = new object[] { + new { @id = 1, @name = "Ada" }, + new { @id = 2, @name = "Bob" }, + }, + @status = "active", + @count = 2, + @tags = new object[] { "a", "b", "c" } + } + } + }; + + var expected = +""" +items[1]: + - users[2]{id,name}: + 1,Ada + 2,Bob + status: active + count: 2 + tags[3]: a,b,c +"""; + + // Act & Assert + var result = ToonEncoder.Encode(input); + + Assert.Equal(expected, result); + } + + [Fact] + [Trait("Description", "multiple list items with tabular first fields")] + public void MultipleListItemsWithTabularFirstFields() + { + // Arrange + var input = + new + { + @items = new object[] { + new + { + @users = new object[] { + new { @id = 1, @name = "Ada" }, + new { @id = 2, @name = "Bob" }, + }, + @status = "active" + }, + new + { + @users = new object[] { + new { @id = 3, @name = "Charlie" } + }, + @status = "inactive" + } + } + }; + + var expected = +""" +items[2]: + - users[2]{id,name}: + 1,Ada + 2,Bob + status: active + - users[1]{id,name}: + 3,Charlie + status: inactive +"""; + + // Act & Assert + var result = ToonEncoder.Encode(input); + + Assert.Equal(expected, result); + } + + [Fact] + [Trait("Description", "deeply nested list items with tabular first field")] + public void DeeplyNestedListItemsWithTabularFirstField() + { + // Arrange + var input = + new + { + @data = new object[] { + new + { + @items = new object[] { + new + { + @users = new object[] { + new { @id = 1, @name = "Ada" }, + new { @id = 2, @name = "Bob" }, + }, + @status = "active" + } + } + } + } + }; + + var expected = +""" +data[1]: + - items[1]: + - users[2]{id,name}: + 1,Ada + 2,Bob + status: active +"""; + + // Act & Assert + var result = ToonEncoder.Encode(input); + + Assert.Equal(expected, result); + } +} diff --git a/tests/ToonFormat.Tests/JsonComplexRoundTripTests.cs b/tests/ToonFormat.Tests/ManualTests/JsonComplexRoundTripTests.cs similarity index 99% rename from tests/ToonFormat.Tests/JsonComplexRoundTripTests.cs rename to tests/ToonFormat.Tests/ManualTests/JsonComplexRoundTripTests.cs index 1d79fb5..7212680 100644 --- a/tests/ToonFormat.Tests/JsonComplexRoundTripTests.cs +++ b/tests/ToonFormat.Tests/ManualTests/JsonComplexRoundTripTests.cs @@ -6,7 +6,7 @@ using Toon.Format; using Xunit.Abstractions; -namespace ToonFormat.Tests; +namespace Toon.Format.Tests; /// /// Tests for complex multi-level JSON structures to validate TOON format encoding and decoding. @@ -129,12 +129,12 @@ public void ComplexJson_RoundTrip_ShouldPreserveKeyFields() Assert.Equal(2.0, phase2["phaseId"]?.GetValue()); Assert.Equal("Development", phase2["title"]?.GetValue()); Assert.Equal("2026-01-30", phase2["deadline"]?.GetValue()); - + var budget = phase2["budget"]?.AsObject(); Assert.NotNull(budget); Assert.Equal("EUR", budget["currency"]?.GetValue()); Assert.Equal(7800.0, budget["amount"]?.GetValue()); - + var resources = phase2["resources"]?.AsObject(); Assert.NotNull(resources); Assert.Equal("alice.smith@example.com", resources["leadDeveloper"]?.GetValue()); @@ -159,7 +159,7 @@ public void ComplexJson_Encode_ShouldProduceValidToonFormat() Assert.Contains("PX-4921", toonText); Assert.Contains("metadata:", toonText); Assert.Contains("phases[2]", toonText); - + _output.WriteLine("TOON Output:"); _output.WriteLine(toonText); } @@ -182,7 +182,7 @@ public void ComplexJson_SpecialCharacters_ShouldBePreserved() var phases = project?["phases"]?.AsArray(); var phase1 = phases?[0]?.AsObject(); var details = phase1?["details"]?.AsObject(); - + Assert.NotNull(details); var specialChars = details["specialChars"]?.GetValue(); Assert.Equal("!@#$%^&*()_+=-{}[]|:;<>,.?/", specialChars); @@ -204,7 +204,7 @@ public void ComplexJson_DateTime_ShouldBePreservedAsString() Assert.NotNull(decoded); var project = decoded["project"]?.AsObject(); Assert.NotNull(project); - + var createdAt = project["createdAt"]?.GetValue(); Assert.NotNull(createdAt); // Validate full ISO 8601 UTC timestamp (with or without fractional seconds) diff --git a/tests/ToonFormat.Tests/KeyFoldingTests.cs b/tests/ToonFormat.Tests/ManualTests/KeyFoldingTests.cs similarity index 99% rename from tests/ToonFormat.Tests/KeyFoldingTests.cs rename to tests/ToonFormat.Tests/ManualTests/KeyFoldingTests.cs index 8fcdd3b..dc30719 100644 --- a/tests/ToonFormat.Tests/KeyFoldingTests.cs +++ b/tests/ToonFormat.Tests/ManualTests/KeyFoldingTests.cs @@ -1,6 +1,6 @@ -using Toon.Format; +using Toon.Format; -namespace ToonFormat.Tests; +namespace Toon.Format.Tests; // TODO: Remove these tests once generated spec tests are in source control // used to validate current key folding functionality aligns with spec diff --git a/tests/ToonFormat.Tests/ManualTests/PerformanceBenchmark.cs b/tests/ToonFormat.Tests/ManualTests/PerformanceBenchmark.cs new file mode 100644 index 0000000..1f90417 --- /dev/null +++ b/tests/ToonFormat.Tests/ManualTests/PerformanceBenchmark.cs @@ -0,0 +1,131 @@ +using System; +using System.Diagnostics; +using System.Text.Json.Nodes; +using Toon.Format; +using Xunit; +using Xunit.Abstractions; + +namespace Toon.Format.Tests; + +public class PerformanceBenchmark +{ + private readonly ITestOutputHelper _output; + + public PerformanceBenchmark(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void BenchmarkSimpleEncoding() + { + var data = new + { + users = new[] + { + new { id = 1, name = "Alice", role = "admin" }, + new { id = 2, name = "Bob", role = "user" }, + new { id = 3, name = "Charlie", role = "user" } + } + }; + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < 10000; i++) + { + _ = ToonEncoder.Encode(data); + } + sw.Stop(); + + _output.WriteLine($"Simple encoding: {sw.ElapsedMilliseconds}ms for 10,000 iterations"); + _output.WriteLine($"Average: {sw.Elapsed.TotalMicroseconds / 10000:F2}μs per encode"); + + // Baseline: should complete in reasonable time (< 5 seconds for 10k iterations) + Assert.True(sw.ElapsedMilliseconds < 5000, $"Encoding took {sw.ElapsedMilliseconds}ms, expected < 5000ms"); + } + + [Fact] + public void BenchmarkSimpleDecoding() + { + var toonString = """ +users[3]{id,name,role}: + 1,Alice,admin + 2,Bob,user + 3,Charlie,user +"""; + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < 10000; i++) + { + _ = ToonDecoder.Decode(toonString); + } + sw.Stop(); + + _output.WriteLine($"Simple decoding: {sw.ElapsedMilliseconds}ms for 10,000 iterations"); + _output.WriteLine($"Average: {sw.Elapsed.TotalMicroseconds / 10000:F2}μs per decode"); + + // Baseline: should complete in reasonable time (< 5 seconds for 10k iterations) + Assert.True(sw.ElapsedMilliseconds < 5000, $"Decoding took {sw.ElapsedMilliseconds}ms, expected < 5000ms"); + } + + [Fact] + public void BenchmarkSection10Encoding() + { + var data = new + { + items = new[] + { + new + { + users = new[] + { + new { id = 1, name = "Ada" }, + new { id = 2, name = "Bob" } + }, + status = "active", + count = 2 + } + } + }; + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < 10000; i++) + { + _ = ToonEncoder.Encode(data); + } + sw.Stop(); + + _output.WriteLine($"Section 10 encoding: {sw.ElapsedMilliseconds}ms for 10,000 iterations"); + _output.WriteLine($"Average: {sw.Elapsed.TotalMicroseconds / 10000:F2}μs per encode"); + + // Baseline: should complete in reasonable time + Assert.True(sw.ElapsedMilliseconds < 5000, $"Section 10 encoding took {sw.ElapsedMilliseconds}ms, expected < 5000ms"); + } + + [Fact] + public void BenchmarkRoundTrip() + { + var data = new + { + users = new[] + { + new { id = 1, name = "Alice" }, + new { id = 2, name = "Bob" } + }, + metadata = new { version = 1, timestamp = "2025-11-27" } + }; + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < 5000; i++) + { + var encoded = ToonEncoder.Encode(data); + _ = ToonDecoder.Decode(encoded); + } + sw.Stop(); + + _output.WriteLine($"Round-trip: {sw.ElapsedMilliseconds}ms for 5,000 iterations"); + _output.WriteLine($"Average: {sw.Elapsed.TotalMicroseconds / 5000:F2}μs per round-trip"); + + // Baseline: should complete in reasonable time + Assert.True(sw.ElapsedMilliseconds < 5000, $"Round-trip took {sw.ElapsedMilliseconds}ms, expected < 5000ms"); + } +} diff --git a/tests/ToonFormat.Tests/ToonAsyncTests.cs b/tests/ToonFormat.Tests/ManualTests/ToonAsyncTests.cs similarity index 99% rename from tests/ToonFormat.Tests/ToonAsyncTests.cs rename to tests/ToonFormat.Tests/ManualTests/ToonAsyncTests.cs index b6db96e..3546d56 100644 --- a/tests/ToonFormat.Tests/ToonAsyncTests.cs +++ b/tests/ToonFormat.Tests/ManualTests/ToonAsyncTests.cs @@ -8,7 +8,7 @@ using Toon.Format; using Xunit; -namespace ToonFormat.Tests; +namespace Toon.Format.Tests; /// /// Tests for async encoding and decoding methods. diff --git a/tests/ToonFormat.Tests/ToonDecoderTests.cs b/tests/ToonFormat.Tests/ManualTests/ToonDecoderTests.cs similarity index 99% rename from tests/ToonFormat.Tests/ToonDecoderTests.cs rename to tests/ToonFormat.Tests/ManualTests/ToonDecoderTests.cs index d2091af..217d9da 100644 --- a/tests/ToonFormat.Tests/ToonDecoderTests.cs +++ b/tests/ToonFormat.Tests/ManualTests/ToonDecoderTests.cs @@ -2,7 +2,7 @@ using System.Text.Json.Nodes; using Toon.Format; -namespace ToonFormat.Tests; +namespace Toon.Format.Tests; /// /// Tests for decoding TOON format strings. diff --git a/tests/ToonFormat.Tests/ToonEncoderTests.cs b/tests/ToonFormat.Tests/ManualTests/ToonEncoderTests.cs similarity index 73% rename from tests/ToonFormat.Tests/ToonEncoderTests.cs rename to tests/ToonFormat.Tests/ManualTests/ToonEncoderTests.cs index 8f20d59..3203596 100644 --- a/tests/ToonFormat.Tests/ToonEncoderTests.cs +++ b/tests/ToonFormat.Tests/ManualTests/ToonEncoderTests.cs @@ -2,7 +2,7 @@ using System.Text.Json.Nodes; using Toon.Format; -namespace ToonFormat.Tests; +namespace Toon.Format.Tests; /// /// Tests for encoding data to TOON format. @@ -136,4 +136,37 @@ public void Encode_NestedStructures_ReturnsValidToon() Assert.Contains("user:", result); Assert.Contains("address:", result); } -} + + [Fact] + public void Encode_ArrayOfPrimitives_Issue7() + { + var data = new[] + { + new + { + ZCHATJID = "18324448539@s.whatsapp.net", + ZMATCHEDTEXT = "http://www.\\u0138roger.com/anniversary", + ZINDEX = "0", + Z_OPT = 2, + Z_PK = 2, + ZSENDERJID = "18324448539@s.whatsapp.net", + ZOWNSTHUMBNAIL = "0", + ZTYPE = "0", + Z_ENT = "10", + ZMESSAGE = "696", + ZSECTIONID = "2017-11", + ZDATE = "531829799", + ZCONTENT1 = "http://www.xn--roger-t5a.com/anniversary" + } + }; + + var expected = """ + [1]{ZCHATJID,ZMATCHEDTEXT,ZINDEX,Z_OPT,Z_PK,ZSENDERJID,ZOWNSTHUMBNAIL,ZTYPE,Z_ENT,ZMESSAGE,ZSECTIONID,ZDATE,ZCONTENT1}: + 18324448539@s.whatsapp.net,"http://www.\\u0138roger.com/anniversary","0",2,2,18324448539@s.whatsapp.net,"0","0","10","696",2017-11,"531829799","http://www.xn--roger-t5a.com/anniversary" + """; + + var result = ToonEncoder.Encode(data); + + Assert.Equal(expected, result); + } +} \ No newline at end of file diff --git a/tests/ToonFormat.Tests/ToonRoundTripTests.cs b/tests/ToonFormat.Tests/ManualTests/ToonRoundTripTests.cs similarity index 98% rename from tests/ToonFormat.Tests/ToonRoundTripTests.cs rename to tests/ToonFormat.Tests/ManualTests/ToonRoundTripTests.cs index 251ee95..7fff4ea 100644 --- a/tests/ToonFormat.Tests/ToonRoundTripTests.cs +++ b/tests/ToonFormat.Tests/ManualTests/ToonRoundTripTests.cs @@ -2,7 +2,7 @@ using System.Text.Json.Nodes; using Toon.Format; -namespace ToonFormat.Tests; +namespace Toon.Format.Tests; /// /// Round-trip tests to verify encoding and decoding preserve data integrity. diff --git a/tests/ToonFormat.Tests/ToonFormat.Tests.csproj b/tests/ToonFormat.Tests/ToonFormat.Tests.csproj index dcfb6b8..1ac006b 100644 --- a/tests/ToonFormat.Tests/ToonFormat.Tests.csproj +++ b/tests/ToonFormat.Tests/ToonFormat.Tests.csproj @@ -5,6 +5,7 @@ enable enable false + Toon.Format.Tests