diff --git a/.gitignore b/.gitignore index 1fac4d5..93067e6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ build/ .idea/modules.xml .idea/jarRepositories.xml .idea/compiler.xml +.idea/misc.xml .idea/libraries/ *.iws *.iml @@ -40,4 +41,4 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store diff --git a/src/main/java/dev/toonformat/jtoon/Delimiter.java b/src/main/java/dev/toonformat/jtoon/Delimiter.java index d163b92..442cec1 100644 --- a/src/main/java/dev/toonformat/jtoon/Delimiter.java +++ b/src/main/java/dev/toonformat/jtoon/Delimiter.java @@ -29,10 +29,6 @@ public enum Delimiter { * Returns the string representation of this delimiter. * @return the string value of this delimiter */ - public String getValue() { - return value; - } - @Override public String toString() { return value; diff --git a/src/main/java/dev/toonformat/jtoon/decoder/ValueDecoder.java b/src/main/java/dev/toonformat/jtoon/decoder/ValueDecoder.java index c55ce0a..c2b8050 100644 --- a/src/main/java/dev/toonformat/jtoon/decoder/ValueDecoder.java +++ b/src/main/java/dev/toonformat/jtoon/decoder/ValueDecoder.java @@ -133,7 +133,7 @@ private static class Parser { Parser(String toon, DecodeOptions options) { this.lines = toon.split("\n", -1); this.options = options; - this.delimiter = options.delimiter().getValue(); + this.delimiter = options.delimiter().toString(); } /** @@ -1467,4 +1467,4 @@ private void validateIndentation(String line) { } } } -} \ No newline at end of file +} diff --git a/src/main/java/dev/toonformat/jtoon/encoder/ArrayEncoder.java b/src/main/java/dev/toonformat/jtoon/encoder/ArrayEncoder.java index a1f3ebe..b212019 100644 --- a/src/main/java/dev/toonformat/jtoon/encoder/ArrayEncoder.java +++ b/src/main/java/dev/toonformat/jtoon/encoder/ArrayEncoder.java @@ -35,7 +35,7 @@ private ArrayEncoder() { */ public static void encodeArray(String key, ArrayNode value, LineWriter writer, int depth, EncodeOptions options) { if (value.isEmpty()) { - String header = PrimitiveEncoder.formatHeader(0, key, null, options.delimiter().getValue(), + String header = PrimitiveEncoder.formatHeader(0, key, null, options.delimiter().toString(), options.lengthMarker()); writer.push(depth, header); return; @@ -133,7 +133,7 @@ public static boolean isArrayOfObjects(JsonNode array) { */ private static void encodeInlinePrimitiveArray(String prefix, ArrayNode values, LineWriter writer, int depth, EncodeOptions options) { - String formatted = formatInlineArray(values, options.delimiter().getValue(), prefix, options.lengthMarker()); + String formatted = formatInlineArray(values, options.delimiter().toString(), prefix, options.lengthMarker()); writer.push(depth, formatted); } @@ -165,13 +165,13 @@ public static String formatInlineArray(ArrayNode values, String delimiter, Strin */ private static void encodeArrayOfArraysAsListItems(String prefix, ArrayNode values, LineWriter writer, int depth, EncodeOptions options) { - String header = PrimitiveEncoder.formatHeader(values.size(), prefix, null, options.delimiter().getValue(), + String header = PrimitiveEncoder.formatHeader(values.size(), prefix, null, options.delimiter().toString(), options.lengthMarker()); writer.push(depth, header); for (JsonNode arr : values) { if (arr.isArray() && isArrayOfPrimitives(arr)) { - String inline = formatInlineArray((ArrayNode) arr, options.delimiter().getValue(), null, + String inline = formatInlineArray((ArrayNode) arr, options.delimiter().toString(), null, options.lengthMarker()); writer.push(depth + 1, LIST_ITEM_PREFIX + inline); } @@ -186,7 +186,7 @@ private static void encodeMixedArrayAsListItems(String prefix, LineWriter writer, int depth, EncodeOptions options) { - String header = PrimitiveEncoder.formatHeader(items.size(), prefix, null, options.delimiter().getValue(), + String header = PrimitiveEncoder.formatHeader(items.size(), prefix, null, options.delimiter().toString(), options.lengthMarker()); writer.push(depth, header); @@ -194,18 +194,18 @@ private static void encodeMixedArrayAsListItems(String prefix, if (item.isValueNode()) { // Direct primitive as list item writer.push(depth + 1, - LIST_ITEM_PREFIX + PrimitiveEncoder.encodePrimitive(item, options.delimiter().getValue())); + LIST_ITEM_PREFIX + PrimitiveEncoder.encodePrimitive(item, options.delimiter().toString())); } else if (item.isArray()) { // Direct array as list item if (isArrayOfPrimitives(item)) { - String inline = formatInlineArray((ArrayNode) item, options.delimiter().getValue(), null, + String inline = formatInlineArray((ArrayNode) item, options.delimiter().toString(), null, options.lengthMarker()); writer.push(depth + 1, LIST_ITEM_PREFIX + inline); } if (isArrayOfObjects(item)) { ArrayNode arrayItems = (ArrayNode) item; String nestedHeader = PrimitiveEncoder.formatHeader(arrayItems.size(), null, null, - options.delimiter().getValue(), options.lengthMarker()); + options.delimiter().toString(), options.lengthMarker()); writer.push(depth + 1, LIST_ITEM_PREFIX + nestedHeader); arrayItems.elements() diff --git a/src/main/java/dev/toonformat/jtoon/encoder/ListItemEncoder.java b/src/main/java/dev/toonformat/jtoon/encoder/ListItemEncoder.java index bc905ba..879335a 100644 --- a/src/main/java/dev/toonformat/jtoon/encoder/ListItemEncoder.java +++ b/src/main/java/dev/toonformat/jtoon/encoder/ListItemEncoder.java @@ -72,7 +72,7 @@ private static void encodeFirstKeyValue(String key, JsonNode value, LineWriter w private static void encodeFirstValueAsPrimitive(String encodedKey, JsonNode value, LineWriter writer, int depth, EncodeOptions options) { writer.push(depth, LIST_ITEM_PREFIX + encodedKey + COLON + SPACE - + PrimitiveEncoder.encodePrimitive(value, options.delimiter().getValue())); + + PrimitiveEncoder.encodePrimitive(value, options.delimiter().toString())); } private static void encodeFirstValueAsArray(String key, String encodedKey, ArrayNode arrayValue, LineWriter writer, @@ -88,7 +88,7 @@ private static void encodeFirstValueAsArray(String key, String encodedKey, Array private static void encodeFirstArrayAsPrimitives(String key, ArrayNode arrayValue, LineWriter writer, int depth, EncodeOptions options) { - String formatted = ArrayEncoder.formatInlineArray(arrayValue, options.delimiter().getValue(), key, + String formatted = ArrayEncoder.formatInlineArray(arrayValue, options.delimiter().toString(), key, options.lengthMarker()); writer.push(depth, LIST_ITEM_PREFIX + formatted); } @@ -98,7 +98,7 @@ private static void encodeFirstArrayAsObjects(String key, String encodedKey, Arr List header = TabularArrayEncoder.detectTabularHeader(arrayValue); if (!header.isEmpty()) { String headerStr = PrimitiveEncoder.formatHeader(arrayValue.size(), key, header, - options.delimiter().getValue(), options.lengthMarker()); + options.delimiter().toString(), options.lengthMarker()); writer.push(depth, LIST_ITEM_PREFIX + headerStr); // Write just the rows, header was already written above TabularArrayEncoder.writeTabularRows(arrayValue, header, writer, depth + 2, options); @@ -120,9 +120,9 @@ private static void encodeFirstArrayAsComplex(String encodedKey, ArrayNode array for (JsonNode item : arrayValue) { if (item.isValueNode()) { writer.push(depth + 2, LIST_ITEM_PREFIX - + PrimitiveEncoder.encodePrimitive(item, options.delimiter().getValue())); + + PrimitiveEncoder.encodePrimitive(item, options.delimiter().toString())); } else if (item.isArray() && ArrayEncoder.isArrayOfPrimitives(item)) { - String inline = ArrayEncoder.formatInlineArray((ArrayNode) item, options.delimiter().getValue(), null, + String inline = ArrayEncoder.formatInlineArray((ArrayNode) item, options.delimiter().toString(), null, options.lengthMarker()); writer.push(depth + 2, LIST_ITEM_PREFIX + inline); } else if (item.isObject()) { diff --git a/src/main/java/dev/toonformat/jtoon/encoder/ObjectEncoder.java b/src/main/java/dev/toonformat/jtoon/encoder/ObjectEncoder.java index 311f8b0..e2d0f4c 100644 --- a/src/main/java/dev/toonformat/jtoon/encoder/ObjectEncoder.java +++ b/src/main/java/dev/toonformat/jtoon/encoder/ObjectEncoder.java @@ -1,6 +1,5 @@ package dev.toonformat.jtoon.encoder; - import dev.toonformat.jtoon.EncodeOptions; import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ArrayNode; @@ -44,16 +43,16 @@ public static void encodeObject(ObjectNode value, LineWriter writer, int depth, if (depth == 0 && rootLiteralKeys != null) { rootLiteralKeys.clear(); fields.stream() - .filter(e -> e.getKey().contains(".")) - .map(Map.Entry::getKey) - .forEach(rootLiteralKeys::add); + .filter(e -> e.getKey().contains(".")) + .map(Map.Entry::getKey) + .forEach(rootLiteralKeys::add); } int effectiveFlattenDepth = remainingDepth != null ? remainingDepth : options.flattenDepth(); //the siblings collision do not need the absolute path Set siblings = fields.stream() - .map(Map.Entry::getKey) - .collect(Collectors.toCollection(LinkedHashSet::new)); + .map(Map.Entry::getKey) + .collect(Collectors.toCollection(LinkedHashSet::new)); for (Map.Entry entry : fields) { encodeKeyValuePair(entry.getKey(), entry.getValue(), writer, depth, options, siblings, rootLiteralKeys, pathPrefix, effectiveFlattenDepth, blockedKeys); @@ -85,6 +84,9 @@ public static void encodeKeyValuePair(String key, Integer flattenDepth, Set blockedKeys ) { + if (key == null) { + return; + } String encodedKey = PrimitiveEncoder.encodeKey(key); String currentPath = pathPrefix != null ? pathPrefix + "." + key : key; int effectiveFlattenDepth = flattenDepth != null && flattenDepth > 0 ? flattenDepth : options.flattenDepth(); @@ -92,10 +94,10 @@ public static void encodeKeyValuePair(String key, // Attempt key folding when enabled if (options.flatten() - && !siblings.isEmpty() - && remainingDepth > 0 - && blockedKeys != null - && !blockedKeys.contains(key)) { + && !siblings.isEmpty() + && remainingDepth > 0 + && blockedKeys != null + && !blockedKeys.contains(key)) { Flatten.FoldResult foldResult = Flatten.tryFoldKeyChain(key, value, siblings, rootLiteralKeys, pathPrefix, remainingDepth); if (foldResult != null) { options = flatten(key, foldResult, writer, depth, options, rootLiteralKeys, pathPrefix, blockedKeys, remainingDepth); @@ -106,7 +108,7 @@ public static void encodeKeyValuePair(String key, } if (value.isValueNode()) { - writer.push(depth, encodedKey + COLON + SPACE + PrimitiveEncoder.encodePrimitive(value, options.delimiter().getValue())); + writer.push(depth, encodedKey + COLON + SPACE + PrimitiveEncoder.encodePrimitive(value, options.delimiter().toString())); } else if (value.isArray()) { ArrayEncoder.encodeArray(key, (ArrayNode) value, writer, depth, options); } else if (value.isObject()) { @@ -132,7 +134,8 @@ public static void encodeKeyValuePair(String key, * @param remainingDepth the depth that remind to the limit * @return EncodeOptions changes for Case 2 */ - private static EncodeOptions flatten(String key, Flatten.FoldResult foldResult, LineWriter writer, int depth, EncodeOptions options, Set rootLiteralKeys, String pathPrefix, Set blockedKeys, int remainingDepth) { + private static EncodeOptions flatten(String key, Flatten.FoldResult foldResult, LineWriter writer, int depth, EncodeOptions options, Set rootLiteralKeys, String pathPrefix, Set blockedKeys, + int remainingDepth) { String foldedKey = foldResult.foldedKey(); // prevent second folding pass @@ -175,10 +178,10 @@ private static void handleFullyFoldedLeaf(Flatten.FoldResult foldResult, LineWri // Primitive if (leaf.isValueNode()) { writer.push(depth, - indentedLine(depth, - encodedFoldedKey + ": " + - PrimitiveEncoder.encodePrimitive(leaf, options.delimiter().getValue()), - options.indent())); + indentedLine(depth, + encodedFoldedKey + ": " + + PrimitiveEncoder.encodePrimitive(leaf, options.delimiter().toString()), + options.indent())); return; } @@ -193,7 +196,7 @@ private static void handleFullyFoldedLeaf(Flatten.FoldResult foldResult, LineWri writer.push(depth, indentedLine(depth, encodedFoldedKey + ":", options.indent())); if (!leaf.isEmpty()) { encodeObject((ObjectNode) leaf, writer, depth + 1, options, - null, null, null, null); + null, null, null, null); } } } diff --git a/src/main/java/dev/toonformat/jtoon/encoder/PrimitiveEncoder.java b/src/main/java/dev/toonformat/jtoon/encoder/PrimitiveEncoder.java index 70bc7fd..68f4543 100644 --- a/src/main/java/dev/toonformat/jtoon/encoder/PrimitiveEncoder.java +++ b/src/main/java/dev/toonformat/jtoon/encoder/PrimitiveEncoder.java @@ -6,6 +6,7 @@ import java.math.BigDecimal; import java.util.List; +import java.util.Objects; import static dev.toonformat.jtoon.util.Constants.*; @@ -108,6 +109,7 @@ public static String encodeKey(String key) { */ public static String joinEncodedValues(List values, String delimiter) { return values.stream() + .filter(Objects::nonNull) .map(v -> encodePrimitive(v, delimiter)) .reduce((a, b) -> a + delimiter + b) .orElse(""); diff --git a/src/main/java/dev/toonformat/jtoon/encoder/TabularArrayEncoder.java b/src/main/java/dev/toonformat/jtoon/encoder/TabularArrayEncoder.java index ee592e9..0a9552f 100644 --- a/src/main/java/dev/toonformat/jtoon/encoder/TabularArrayEncoder.java +++ b/src/main/java/dev/toonformat/jtoon/encoder/TabularArrayEncoder.java @@ -6,6 +6,7 @@ import tools.jackson.databind.node.ObjectNode; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -27,26 +28,26 @@ private TabularArrayEncoder() { */ public static List detectTabularHeader(ArrayNode rows) { if (rows.isEmpty()) { - return new ArrayList<>(); + return Collections.emptyList(); } JsonNode firstRow = rows.get(0); if (!firstRow.isObject()) { - return new ArrayList<>(); + return Collections.emptyList(); } ObjectNode firstObj = (ObjectNode) firstRow; List firstKeys = new ArrayList<>(firstObj.propertyNames()); if (firstKeys.isEmpty()) { - return new ArrayList<>(); + return Collections.emptyList(); } if (isTabularArray(rows, firstKeys)) { return firstKeys; } - return new ArrayList<>(); + return Collections.emptyList(); } /** @@ -92,7 +93,7 @@ private static boolean isTabularArray(ArrayNode rows, List header) { */ public static void encodeArrayOfObjectsAsTabular(String prefix, ArrayNode rows, List header, LineWriter writer, int depth, EncodeOptions options) { - String headerStr = PrimitiveEncoder.formatHeader(rows.size(), prefix, header, options.delimiter().getValue(), + String headerStr = PrimitiveEncoder.formatHeader(rows.size(), prefix, header, options.delimiter().toString(), options.lengthMarker()); writer.push(depth, headerStr); @@ -112,12 +113,16 @@ public static void encodeArrayOfObjectsAsTabular(String prefix, ArrayNode rows, public static void writeTabularRows(ArrayNode rows, List header, LineWriter writer, int depth, EncodeOptions options) { for (JsonNode row : rows) { + //skip non-object rows + if (!row.isObject()) { + continue; + } ObjectNode obj = (ObjectNode) row; List values = new ArrayList<>(); for (String key : header) { values.add(obj.get(key)); } - String joinedValue = PrimitiveEncoder.joinEncodedValues(values, options.delimiter().getValue()); + String joinedValue = PrimitiveEncoder.joinEncodedValues(values, options.delimiter().toString()); writer.push(depth, joinedValue); } } diff --git a/src/main/java/dev/toonformat/jtoon/encoder/ValueEncoder.java b/src/main/java/dev/toonformat/jtoon/encoder/ValueEncoder.java index 0c30f1e..697c89f 100644 --- a/src/main/java/dev/toonformat/jtoon/encoder/ValueEncoder.java +++ b/src/main/java/dev/toonformat/jtoon/encoder/ValueEncoder.java @@ -28,7 +28,7 @@ private ValueEncoder() { public static String encodeValue(JsonNode value, EncodeOptions options) { // Handle primitive values directly if (value.isValueNode()) { - return PrimitiveEncoder.encodePrimitive(value, options.delimiter().getValue()); + return PrimitiveEncoder.encodePrimitive(value, options.delimiter().toString()); } // Complex values need a LineWriter for indentation diff --git a/src/main/java/dev/toonformat/jtoon/normalizer/JsonNormalizer.java b/src/main/java/dev/toonformat/jtoon/normalizer/JsonNormalizer.java index da35d6e..9f00151 100644 --- a/src/main/java/dev/toonformat/jtoon/normalizer/JsonNormalizer.java +++ b/src/main/java/dev/toonformat/jtoon/normalizer/JsonNormalizer.java @@ -24,9 +24,16 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; -import java.util.*; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.TimeZone; import java.util.function.Function; import java.util.function.IntFunction; import java.util.stream.Stream; @@ -36,25 +43,27 @@ * Handles Java-specific types like LocalDateTime, Optional, Stream, etc. */ public final class JsonNormalizer { - /** Shared ObjectMapper instance configured for JSON normalization. */ + /** + * Shared ObjectMapper instance configured for JSON normalization. + */ public static final ObjectMapper MAPPER; static { MAPPER = JsonMapper.builder() - .changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.ALWAYS)) - .addModule(new AfterburnerModule().setUseValueClassLoader(true)) // Speeds up Jackson by 20–40% in most real-world cases - // .disable(MapperFeature.DEFAULT_VIEW_INCLUSION) in Jackson 3 this is default disabled - // .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) in Jackson 3 this is default disabled - .defaultTimeZone(TimeZone.getTimeZone("UTC")) // set a default timezone for dates - .build(); + .changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.ALWAYS)) + .addModule(new AfterburnerModule().setUseValueClassLoader(true)) // Speeds up Jackson by 20–40% in most real-world cases + // .disable(MapperFeature.DEFAULT_VIEW_INCLUSION) in Jackson 3 this is default disabled + // .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) in Jackson 3 this is default disabled + .defaultTimeZone(TimeZone.getTimeZone("UTC")) // set a default timezone for dates + .build(); } private static final List> NORMALIZERS = List.of( - JsonNormalizer::tryNormalizePrimitive, - JsonNormalizer::tryNormalizeBigNumber, - JsonNormalizer::tryNormalizeTemporal, - JsonNormalizer::tryNormalizeCollection, - JsonNormalizer::tryNormalizePojo); + JsonNormalizer::tryNormalizePrimitive, + JsonNormalizer::tryNormalizeBigNumber, + JsonNormalizer::tryNormalizeTemporal, + JsonNormalizer::tryNormalizeCollection, + JsonNormalizer::tryNormalizePojo); private JsonNormalizer() { throw new UnsupportedOperationException("Utility class cannot be instantiated"); @@ -110,10 +119,10 @@ public static JsonNode normalize(Object value) { */ private static JsonNode normalizeWithStrategy(Object value) { return NORMALIZERS.stream() - .map(normalizer -> normalizer.apply(value)) - .filter(Objects::nonNull) - .findFirst() - .orElse(NullNode.getInstance()); + .map(normalizer -> normalizer.apply(value)) + .filter(Objects::nonNull) + .findFirst() + .orElse(NullNode.getInstance()); } /** @@ -153,7 +162,7 @@ private static JsonNode normalizeDouble(Double value) { return IntNode.valueOf(0); } return tryConvertToLong(value) - .orElse(DoubleNode.valueOf(value)); + .orElse(DoubleNode.valueOf(value)); } /** @@ -161,8 +170,8 @@ private static JsonNode normalizeDouble(Double value) { */ private static JsonNode normalizeFloat(Float value) { return Float.isFinite(value) - ? FloatNode.valueOf(value) - : NullNode.getInstance(); + ? FloatNode.valueOf(value) + : NullNode.getInstance(); } /** @@ -177,8 +186,8 @@ private static Optional tryConvertToLong(Double value) { } long longVal = value.longValue(); return longVal == value - ? Optional.of(LongNode.valueOf(longVal)) - : Optional.empty(); + ? Optional.of(LongNode.valueOf(longVal)) + : Optional.empty(); } /** @@ -200,10 +209,10 @@ private static JsonNode tryNormalizeBigNumber(Object value) { */ private static JsonNode normalizeBigInteger(BigInteger value) { boolean fitsInLong = value.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) <= 0 - && value.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) >= 0; + && value.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) >= 0; return fitsInLong - ? LongNode.valueOf(value.longValue()) - : StringNode.valueOf(value.toString()); + ? LongNode.valueOf(value.longValue()) + : StringNode.valueOf(value.toString()); } /** @@ -224,7 +233,7 @@ private static JsonNode tryNormalizeTemporal(Object value) { } else if (value instanceof Instant instant) { return StringNode.valueOf(instant.toString()); } else if (value instanceof Date date) { - return StringNode.valueOf(date.toInstant().toString()); + return StringNode.valueOf(LocalDate.ofInstant(date.toInstant(), ZoneId.systemDefault()).toString()); } else { return null; } @@ -324,8 +333,8 @@ private static ArrayNode buildArrayNode(int length, IntFunction mapper */ private static JsonNode normalizeDoubleElement(double value) { return Double.isFinite(value) - ? DoubleNode.valueOf(value) - : NullNode.getInstance(); + ? DoubleNode.valueOf(value) + : NullNode.getInstance(); } /** @@ -333,7 +342,7 @@ private static JsonNode normalizeDoubleElement(double value) { */ private static JsonNode normalizeFloatElement(float value) { return Float.isFinite(value) - ? FloatNode.valueOf(value) - : NullNode.getInstance(); + ? FloatNode.valueOf(value) + : NullNode.getInstance(); } } diff --git a/src/main/java/dev/toonformat/jtoon/util/StringValidator.java b/src/main/java/dev/toonformat/jtoon/util/StringValidator.java index d4405ca..5c28c89 100644 --- a/src/main/java/dev/toonformat/jtoon/util/StringValidator.java +++ b/src/main/java/dev/toonformat/jtoon/util/StringValidator.java @@ -99,7 +99,7 @@ private static boolean containsColon(String value) { return value.contains(COLON); } - private static boolean containsQuotesOrBackslash(String value) { + static boolean containsQuotesOrBackslash(String value) { return value.indexOf(DOUBLE_QUOTE) >= 0 || value.indexOf(BACKSLASH) >= 0; } diff --git a/src/test/java/dev/toonformat/jtoon/encoder/ArrayEncoderTest.java b/src/test/java/dev/toonformat/jtoon/encoder/ArrayEncoderTest.java index f80e932..cc084e2 100644 --- a/src/test/java/dev/toonformat/jtoon/encoder/ArrayEncoderTest.java +++ b/src/test/java/dev/toonformat/jtoon/encoder/ArrayEncoderTest.java @@ -1,54 +1,138 @@ package dev.toonformat.jtoon.encoder; +import dev.toonformat.jtoon.EncodeOptions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.JsonNodeFactory; import tools.jackson.databind.node.ObjectNode; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; class ArrayEncoderTest { - ObjectMapper MAPPER = new ObjectMapper(); + + private final ObjectMapper MAPPER = new ObjectMapper(); + private final JsonNodeFactory jsonNodeFactory = JsonNodeFactory.instance; @Test - void isArrayOfPrimitives() { - //given + void isArrayOfPrimitivesTestWithObjectNode() { + // Given ObjectNode dataTable = MAPPER.createObjectNode(); - //when + // When boolean arrayOfArrays = ArrayEncoder.isArrayOfPrimitives(dataTable); - //then + // Then assertFalse(arrayOfArrays); } @Test - void isArrayOfArrays() { - //given + @DisplayName("given array-of-arrays with mixed inner types when encodeArrayOfArraysAsListItems then writes header and primitive list items") + void givenArrayOfArraysWithMixedInnerTypes_whenEncodePrivate_thenWritesExpected() throws Exception { + // Given + ArrayNode outer = jsonNodeFactory.arrayNode(); + + ArrayNode innerPrims = jsonNodeFactory.arrayNode().add(1).add(2); + ArrayNode innerObjects = jsonNodeFactory.arrayNode(); + innerObjects.add(jsonNodeFactory.objectNode().put("a", 1)); + outer.add(innerPrims).add(innerObjects).add("x"); + + EncodeOptions options = EncodeOptions.DEFAULT; + LineWriter writer = new LineWriter(options.indent()); + + Method method = ArrayEncoder.class.getDeclaredMethod( + "encodeArrayOfArraysAsListItems", + String.class, + ArrayNode.class, + LineWriter.class, + int.class, + EncodeOptions.class + ); + method.setAccessible(true); + + // When + method.invoke(null, "items", outer, writer, 0, options); + + // Then + String expected = String.join("\n", + "items[3]:", + " - [2]: 1,2" + ); + assertEquals(expected, writer.toString()); + } + + @Test + void isArrayOfArraysTestWithObjectNode() { + // Given ObjectNode dataTable = MAPPER.createObjectNode(); - //when + // When boolean arrayOfArrays = ArrayEncoder.isArrayOfArrays(dataTable); - //then + // Then assertFalse(arrayOfArrays); } @Test - void isArrayOfObjects() { - //given + void isArrayOfObjectsTestWithObjectNode() { + // Given ObjectNode dataTable = MAPPER.createObjectNode(); - //when + // When boolean arrayOfArrays = ArrayEncoder.isArrayOfObjects(dataTable); - //then + // Then assertFalse(arrayOfArrays); } + @Test + void encodeArrayWithAllPrimitives() { + // Given + ArrayNode arrayNode = jsonNodeFactory.arrayNode(); + arrayNode.add(1).add(2).add(3); + EncodeOptions options = EncodeOptions.DEFAULT; + LineWriter lineWriter = new LineWriter(options.indent()); + + // When + ArrayEncoder.encodeArray("", arrayNode, lineWriter, 1, options); + + // Then + assertFalse(lineWriter.toString().isBlank()); + assertEquals(" \"\"[3]: 1,2,3", lineWriter.toString()); + } + + @Test + void encodeArrayWithAllPrimitivesArrayOfArrays() { + // Given + ArrayNode arrayNode = jsonNodeFactory.arrayNode(); + ArrayNode innerArrayNode = jsonNodeFactory.arrayNode(); + innerArrayNode.add(1).add(2).add(3); + ArrayNode innerArrayNode2 = jsonNodeFactory.arrayNode(); + innerArrayNode2.add(4).add(5).add(6); + + arrayNode.add(innerArrayNode).add(innerArrayNode2); + + EncodeOptions options = EncodeOptions.DEFAULT; + LineWriter lineWriter = new LineWriter(options.indent()); + + // When + ArrayEncoder.encodeArray("", arrayNode, lineWriter, 1, options); + + // Then + assertFalse(lineWriter.toString().isBlank()); + assertEquals(" \"\"[2]:\n" + + " - [3]: 1,2,3\n" + + " - [3]: 4,5,6", lineWriter.toString()); + } + @Test @DisplayName("throws unsupported Operation Exception for calling the constructor") void throwsOnConstructor() throws NoSuchMethodException { @@ -56,10 +140,10 @@ void throwsOnConstructor() throws NoSuchMethodException { constructor.setAccessible(true); final InvocationTargetException thrown = - assertThrows(InvocationTargetException.class, constructor::newInstance); + assertThrows(InvocationTargetException.class, constructor::newInstance); final Throwable cause = thrown.getCause(); assertInstanceOf(UnsupportedOperationException.class, cause); assertEquals("Utility class cannot be instantiated", cause.getMessage()); } -} \ No newline at end of file +} diff --git a/src/test/java/dev/toonformat/jtoon/encoder/FlattenTest.java b/src/test/java/dev/toonformat/jtoon/encoder/FlattenTest.java index 4d46cd7..c2d39ae 100644 --- a/src/test/java/dev/toonformat/jtoon/encoder/FlattenTest.java +++ b/src/test/java/dev/toonformat/jtoon/encoder/FlattenTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.ObjectNode; import java.lang.reflect.Constructor; @@ -16,6 +17,7 @@ * Test for Flatten */ class FlattenTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); @Test @@ -25,7 +27,7 @@ void throwsOnConstructor() throws NoSuchMethodException { constructor.setAccessible(true); final InvocationTargetException thrown = - assertThrows(InvocationTargetException.class, constructor::newInstance); + assertThrows(InvocationTargetException.class, constructor::newInstance); final Throwable cause = thrown.getCause(); assertInstanceOf(UnsupportedOperationException.class, cause); @@ -45,7 +47,7 @@ void givenValidSingleKeyChain_whenTryFold_thenFoldsSuccessfully() { // When Flatten.FoldResult result = Flatten.tryFoldKeyChain( - "a", a, siblings, rootLiteral, null, 10 + "a", a, siblings, rootLiteral, null, 10 ); // Then @@ -63,7 +65,7 @@ void givenNonObjectValue_whenTryFold_thenReturnsNull() { // When Flatten.FoldResult result = Flatten.tryFoldKeyChain( - "x", value, Set.of(), Set.of(), null, 10 + "x", value, Set.of(), Set.of(), null, 10 ); // Then @@ -78,7 +80,7 @@ void givenSingleSegmentChain_whenTryFold_thenReturnsNull() { // When Flatten.FoldResult result = Flatten.tryFoldKeyChain( - "a", node.get("a"), Set.of(), Set.of(), null, 10 + "a", node.get("a"), Set.of(), Set.of(), null, 10 ); // Then @@ -94,7 +96,7 @@ void givenChainWithInvalidIdentifier_whenTryFold_thenReturnsNull() { // When Flatten.FoldResult result = Flatten.tryFoldKeyChain( - "a", a, Set.of(), Set.of(), null, 10 + "a", a, Set.of(), Set.of(), null, 10 ); // Then @@ -112,7 +114,7 @@ void givenSiblingCollision_whenTryFold_thenReturnsNull() { // When Flatten.FoldResult result = Flatten.tryFoldKeyChain( - "a", a, siblings, Set.of(), null, 2 + "a", a, siblings, Set.of(), null, 2 ); // Then @@ -130,7 +132,7 @@ void givenRootLiteralCollision_whenTryFold_thenReturnsNull() { // When Flatten.FoldResult result = Flatten.tryFoldKeyChain( - "a", a, Set.of(), rootLiteral, "root", 10 + "a", a, Set.of(), rootLiteral, "root", 10 ); // Then @@ -146,13 +148,104 @@ void givenDepthLimitReached_whenTryFold_thenReturnsNull() { // When Flatten.FoldResult result = Flatten.tryFoldKeyChain( - "a", a, Set.of(), Set.of(), null, 1 + "a", a, Set.of(), Set.of(), null, 1 + ); + + // Then + assertNull(result); + } + + @Test + void testTryFoldKeyChainWithArrayNode() { + // Given + ArrayNode a = MAPPER.createArrayNode(); + + // When + Flatten.FoldResult result = Flatten.tryFoldKeyChain("a", a, Set.of(), Set.of(), null, 10); + + // Then + assertNull(result); + } + + @Test + void testTryFoldKeyChainWithSmallRemainingDepth() { + // Given + ObjectNode a = MAPPER.createObjectNode(); + ObjectNode b = a.putObject("b"); + b.put("x", 1); + b.put("y", 2); + + // When + Flatten.FoldResult result = Flatten.tryFoldKeyChain( + "a", a, Set.of(), Set.of(), null, 0 ); // Then assertNull(result); } + @Test + void testTryFoldKeyChainWithPathPrefix() { + // Given + ObjectNode a = MAPPER.createObjectNode(); + ObjectNode b = a.putObject("b"); + b.put("x", 1); + b.put("y", 2); + + // When + Flatten.FoldResult result = Flatten.tryFoldKeyChain( + "a", a, Set.of(), Set.of(), "items", 10 + ); + + // Then + assertNotNull(result); + assertEquals("a.b", result.foldedKey()); + assertNotNull(result.remainder()); + assertNull(result.leafValue()); + assertEquals(2, result.segmentCount()); + } + + @Test + void testTryFoldKeyChainWithDotsInKey() { + // Given + ObjectNode a = MAPPER.createObjectNode(); + ObjectNode b = a.putObject("b"); + b.put("x", 1); + b.put("y", 2); + + // When + Flatten.FoldResult result = Flatten.tryFoldKeyChain( + "c.d", a, Set.of(), Set.of(), null, 10 + ); + + // Then + assertNotNull(result); + assertEquals("d.b", result.foldedKey()); + assertNotNull(result.remainder()); + assertNull(result.leafValue()); + assertEquals(2, result.segmentCount()); + } + + @Test + void testTryFoldKeyChainWithSimpleObjectNode() { + // Given + ObjectNode a = MAPPER.createObjectNode(); + a.put("item", 42); + + // When + Flatten.FoldResult result = Flatten.tryFoldKeyChain( + "a", a, Set.of(), Set.of(), null, 10 + ); + + // Then + assertNotNull(result); + assertEquals("a.item", result.foldedKey()); + assertNull(result.remainder()); + assertNotNull(result.leafValue()); + assertEquals(42, result.leafValue().asInt()); + assertEquals(2, result.segmentCount()); + } + @Test void givenTailObjectWithMultipleKeys_whenTryFold_thenReturnsTailInResult() { // Given @@ -163,7 +256,7 @@ void givenTailObjectWithMultipleKeys_whenTryFold_thenReturnsTailInResult() { // When Flatten.FoldResult result = Flatten.tryFoldKeyChain( - "a", a, Set.of(), Set.of(), null, 10 + "a", a, Set.of(), Set.of(), null, 10 ); // Then @@ -182,7 +275,7 @@ void givenEmptyObjectLeaf_whenTryFold_thenLeafIsReturned() { // When Flatten.FoldResult result = Flatten.tryFoldKeyChain( - "a", a, Set.of(), Set.of(), null, 10 + "a", a, Set.of(), Set.of(), null, 10 ); // Then diff --git a/src/test/java/dev/toonformat/jtoon/encoder/HeaderFormatterTest.java b/src/test/java/dev/toonformat/jtoon/encoder/HeaderFormatterTest.java index a0e511d..0d09a82 100644 --- a/src/test/java/dev/toonformat/jtoon/encoder/HeaderFormatterTest.java +++ b/src/test/java/dev/toonformat/jtoon/encoder/HeaderFormatterTest.java @@ -8,6 +8,8 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.stream.Stream; @@ -286,4 +288,18 @@ void testNestedArray() { assertEquals("items[3]{sku,qty,price}:", result); } } + + @Test + @DisplayName("throws unsupported Operation Exception for calling the constructor") + void throwsOnConstructor() throws NoSuchMethodException { + final Constructor constructor = HeaderFormatter.class.getDeclaredConstructor(); + constructor.setAccessible(true); + + final InvocationTargetException thrown = + assertThrows(InvocationTargetException.class, constructor::newInstance); + + final Throwable cause = thrown.getCause(); + assertInstanceOf(UnsupportedOperationException.class, cause); + assertEquals("Utility class cannot be instantiated", cause.getMessage()); + } } diff --git a/src/test/java/dev/toonformat/jtoon/encoder/ListItemEncoderTest.java b/src/test/java/dev/toonformat/jtoon/encoder/ListItemEncoderTest.java index 076d186..f407f5c 100644 --- a/src/test/java/dev/toonformat/jtoon/encoder/ListItemEncoderTest.java +++ b/src/test/java/dev/toonformat/jtoon/encoder/ListItemEncoderTest.java @@ -166,4 +166,34 @@ void usesListFormatForNestedObjectArraysWithMismatchedKeys() { " status: active"); assertEquals(expected, writer.toString()); } -} \ No newline at end of file + + @Test + @DisplayName("given mixed-type array as first value when encoded then writes complex list format") + void givenMixedTypeArrayAsFirstValue_whenEncoded_thenWritesComplexListFormat() { + // Given + ObjectNode obj = jsonNodeFactory.objectNode(); + ArrayNode mixed = jsonNodeFactory.arrayNode(); + // primitive + mixed.add(1); + // array of primitives + mixed.add(jsonNodeFactory.arrayNode().add(10).add(11)); + // object + ObjectNode nested = jsonNodeFactory.objectNode(); + nested.put("a", 5); + mixed.add(nested); + obj.set("mixed", mixed); + + LineWriter writer = new LineWriter(options.indent()); + + // When + ListItemEncoder.encodeObjectAsListItem(obj, writer, 0, options); + + // Then + String expected = String.join("\n", + "- mixed[3]:", + " - 1", + " - [2]: 10,11", + " - a: 5"); + assertEquals(expected, writer.toString()); + } +} diff --git a/src/test/java/dev/toonformat/jtoon/encoder/ObjectEncoderTest.java b/src/test/java/dev/toonformat/jtoon/encoder/ObjectEncoderTest.java index e3bb83c..a620721 100644 --- a/src/test/java/dev/toonformat/jtoon/encoder/ObjectEncoderTest.java +++ b/src/test/java/dev/toonformat/jtoon/encoder/ObjectEncoderTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.JsonNodeFactory; import tools.jackson.databind.node.ObjectNode; import java.lang.reflect.Constructor; @@ -22,6 +23,8 @@ class ObjectEncoderTest { private static final ObjectMapper MAPPER = new ObjectMapper(); + private final JsonNodeFactory jsonNodeFactory = JsonNodeFactory.instance; + @Test void givenSimpleObject_whenEncoding_thenOutputsCorrectLines() { // Given @@ -38,6 +41,218 @@ void givenSimpleObject_whenEncoding_thenOutputsCorrectLines() { assertEquals("x: 10", writer.toString()); } + @Test + @DisplayName("given fully-folded primitive leaf when flatten then writes inline value and returns null") + void givenFullyFoldedPrimitiveLeaf_whenFlatten_thenWritesInlineAndReturnsNull() throws Exception { + // Given + LineWriter writer = new LineWriter(EncodeOptions.DEFAULT.indent()); + EncodeOptions options = EncodeOptions.DEFAULT; + Set blockedKeys = new HashSet<>(); + String key = "a"; + + Flatten.FoldResult foldResult = new Flatten.FoldResult( + "a.b", + null, + new ObjectMapper().readTree("42"), + 2 + ); + + Method flattenMethod = ObjectEncoder.class.getDeclaredMethod( + "flatten", + String.class, + Flatten.FoldResult.class, + LineWriter.class, + int.class, + EncodeOptions.class, + Set.class, + String.class, + Set.class, + int.class + ); + flattenMethod.setAccessible(true); + + // When + Object result = flattenMethod.invoke( + null, + key, + foldResult, + writer, + 0, + options, + null, + null, + blockedKeys, + 5 + ); + + // Then + assertNull(result); + assertEquals("a.b: 42", writer.toString()); + assertTrue(blockedKeys.contains("a")); + assertTrue(blockedKeys.contains("a.b")); + } + + @Test + @DisplayName("given fully-folded array leaf when flatten then delegates to ArrayEncoder and returns null") + void givenFullyFoldedArrayLeaf_whenFlatten_thenWritesArrayAndReturnsNull() throws Exception { + // Given + LineWriter writer = new LineWriter(EncodeOptions.DEFAULT.indent()); + EncodeOptions options = EncodeOptions.DEFAULT; + Set blockedKeys = new HashSet<>(); + String key = "items"; + + ArrayNode arrayLeaf = (ArrayNode) new ObjectMapper().readTree("[1,2]"); + Flatten.FoldResult foldResult = new Flatten.FoldResult( + "items.values", + null, + arrayLeaf, + 2 + ); + + Method flattenMethod = ObjectEncoder.class.getDeclaredMethod( + "flatten", + String.class, + Flatten.FoldResult.class, + LineWriter.class, + int.class, + EncodeOptions.class, + Set.class, + String.class, + Set.class, + int.class + ); + flattenMethod.setAccessible(true); + + // When + Object result = flattenMethod.invoke( + null, + key, + foldResult, + writer, + 0, + options, + null, + null, + blockedKeys, + 5 + ); + + // Then + assertNull(result); + assertEquals("items.values[2]: 1,2", writer.toString()); + assertTrue(blockedKeys.contains("items")); + assertTrue(blockedKeys.contains("items.values")); + } + + @Test + @DisplayName("given fully-folded object leaf when flatten then writes header and nested object and returns null") + void givenFullyFoldedObjectLeaf_whenFlatten_thenWritesObjectAndReturnsNull() throws Exception { + // Given + LineWriter writer = new LineWriter(EncodeOptions.DEFAULT.indent()); + EncodeOptions options = EncodeOptions.DEFAULT; + Set blockedKeys = new HashSet<>(); + String key = "user"; + + ObjectNode objectLeaf = (ObjectNode) new ObjectMapper().readTree("{\"id\":1}"); + Flatten.FoldResult foldResult = new Flatten.FoldResult( + "user.info", + null, + objectLeaf, + 2 + ); + + Method flattenMethod = ObjectEncoder.class.getDeclaredMethod( + "flatten", + String.class, + Flatten.FoldResult.class, + LineWriter.class, + int.class, + EncodeOptions.class, + Set.class, + String.class, + Set.class, + int.class + ); + flattenMethod.setAccessible(true); + + // When + Object result = flattenMethod.invoke( + null, + key, + foldResult, + writer, + 0, + options, + null, + null, + blockedKeys, + 5 + ); + + // Then + assertNull(result); + String expected = String.join("\n", + "user.info:", + " id: 1" + ); + assertEquals(expected, writer.toString()); + assertTrue(blockedKeys.contains("user")); + assertTrue(blockedKeys.contains("user.info")); + } + + @Test + @DisplayName("given non-object remainder when flatten then returns options (not null) and writes nothing") + void givenNonObjectRemainder_whenFlatten_thenReturnsOptionsNotNullAndNoOutput() throws Exception { + // Given + LineWriter writer = new LineWriter(EncodeOptions.DEFAULT.indent()); + EncodeOptions options = EncodeOptions.DEFAULT; + Set blockedKeys = new HashSet<>(); + String key = "cfg"; + + ArrayNode remainderArray = (ArrayNode) new ObjectMapper().readTree("[1]"); + Flatten.FoldResult foldResult = new Flatten.FoldResult( + "cfg.path", + remainderArray, + null, + 2 + ); + + Method flattenMethod = ObjectEncoder.class.getDeclaredMethod( + "flatten", + String.class, + Flatten.FoldResult.class, + LineWriter.class, + int.class, + EncodeOptions.class, + Set.class, + String.class, + Set.class, + int.class + ); + flattenMethod.setAccessible(true); + + // When + Object result = flattenMethod.invoke( + null, + key, + foldResult, + writer, + 0, + options, + null, + null, + blockedKeys, + 5 + ); + + // Then + assertNotNull(result, "flatten should not always return null"); + assertSame(options, result, "Expected the same options instance to be returned"); + assertEquals("", writer.toString(), "No output should be produced for non-object remainder"); + assertTrue(blockedKeys.contains("cfg")); + assertTrue(blockedKeys.contains("cfg.path")); + } + @Test void givenNestedObjectAndFlattenOff_whenEncoding_thenWritesIndentedBlocks() { // Given @@ -353,4 +568,105 @@ void usesListFormatForObjectsContainingArraysOfArrays() { assertEquals(expected, writer.toString()); } -} \ No newline at end of file + @Test + void testEncodeKeyValuePairWithAKey() { + // Given + String json = "{\n" + + " \"items\": [\n" + + " { \"matrix\": [[1, 2], [3, 4]], \"name\": \"grid\" }\n" + + " ]\n" + + " }"; + ObjectNode node = (ObjectNode) new ObjectMapper().readTree(json); + + EncodeOptions options = EncodeOptions.withFlatten(true); + LineWriter writer = new LineWriter(options.indent()); + Set rootKeys = new HashSet<>(); + + // When + ObjectEncoder.encodeKeyValuePair("items", node, writer, 0, options, rootKeys, null, null, 10, new HashSet<>()); + + // Then + String expected = String.join("\n", + "items:", + " items[1]:", + " - matrix[2]:", + " - [2]: 1,2", + " - [2]: 3,4", + " name: grid"); + assertEquals(expected, writer.toString()); + } + @Test + void testEncodeKeyValuePairWithNullFlattenDepth() { + // Given + String json = "{\n" + + " \"items\": [\n" + + " { \"matrix\": [[1, 2], [3, 4]], \"name\": \"grid\" }\n" + + " ]\n" + + " }"; + ObjectNode node = (ObjectNode) new ObjectMapper().readTree(json); + + EncodeOptions options = EncodeOptions.withFlatten(true); + LineWriter writer = new LineWriter(options.indent()); + Set rootKeys = new HashSet<>(); + + // When + ObjectEncoder.encodeKeyValuePair("items", node, writer, 0, options, rootKeys, null, null, null, new HashSet<>()); + + // Then + String expected = String.join("\n", + "items:", + " items[1]:", + " - matrix[2]:", + " - [2]: 1,2", + " - [2]: 3,4", + " name: grid"); + assertEquals(expected, writer.toString()); + } + @Test + void testEncodeKeyValuePairWithToSmallFlattenDepth() { + // Given + String json = "{\n" + + " \"items\": [\n" + + " { \"matrix\": [[1, 2], [3, 4]], \"name\": \"grid\" }\n" + + " ]\n" + + " }"; + ObjectNode node = (ObjectNode) new ObjectMapper().readTree(json); + + EncodeOptions options = EncodeOptions.withFlatten(true); + LineWriter writer = new LineWriter(options.indent()); + Set rootKeys = new HashSet<>(); + + // When + ObjectEncoder.encodeKeyValuePair("items", node, writer, 0, options, rootKeys, null, null, 0, new HashSet<>()); + + // Then + String expected = String.join("\n", + "items:", + " items[1]:", + " - matrix[2]:", + " - [2]: 1,2", + " - [2]: 3,4", + " name: grid"); + assertEquals(expected, writer.toString()); + } + + @Test + void testEncodeKeyValuePairWithoutEmptySiblings() { + // Given + ObjectNode node = jsonNodeFactory.objectNode(); + + EncodeOptions options = EncodeOptions.withFlatten(true); + LineWriter writer = new LineWriter(options.indent()); + Set siblings = new HashSet<>(); + siblings.add("hello"); + siblings.add("world"); + + // When + ObjectEncoder.encodeKeyValuePair("", node, writer, 0, options, siblings, null, null, 10, new HashSet<>()); + + // Then + assertFalse(writer.toString().trim().isEmpty()); + //we only get a String with "" + } + +} diff --git a/src/test/java/dev/toonformat/jtoon/encoder/TabularArrayEncoderTest.java b/src/test/java/dev/toonformat/jtoon/encoder/TabularArrayEncoderTest.java new file mode 100644 index 0000000..00bd433 --- /dev/null +++ b/src/test/java/dev/toonformat/jtoon/encoder/TabularArrayEncoderTest.java @@ -0,0 +1,305 @@ +package dev.toonformat.jtoon.encoder; + +import dev.toonformat.jtoon.EncodeOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.JsonNodeFactory; +import tools.jackson.databind.node.ObjectNode; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class TabularArrayEncoderTest { + + private final JsonNodeFactory jsonNodeFactory = JsonNodeFactory.instance; + private final EncodeOptions options = EncodeOptions.DEFAULT; + + @Test + @DisplayName("throws unsupported Operation Exception for calling the constructor") + void throwsOnConstructor() throws NoSuchMethodException { + // Given + final Constructor constructor = TabularArrayEncoder.class.getDeclaredConstructor(); + constructor.setAccessible(true); + + // When + final InvocationTargetException thrown = + assertThrows(InvocationTargetException.class, constructor::newInstance); + + // Then + final Throwable cause = thrown.getCause(); + assertInstanceOf(UnsupportedOperationException.class, cause); + assertEquals("Utility class cannot be instantiated", cause.getMessage()); + } + + @Test + void givenEmptyArray_whenDetectHeader_thenReturnsEmpty() { + // Given + ArrayNode rows = jsonNodeFactory.arrayNode(); + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertTrue(header.isEmpty()); + } + + @Test + void givenFirstRowNotObject_whenDetectHeader_thenReturnsEmpty() { + // Given + ArrayNode rows = jsonNodeFactory.arrayNode().add(1).add(2); + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertTrue(header.isEmpty()); + } + + @Test + void givenFirstObjectHasNoKeys_whenDetectHeader_thenReturnsEmpty() { + // Given + ArrayNode rows = jsonNodeFactory.arrayNode(); + rows.add(jsonNodeFactory.objectNode()); // empty object + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertTrue(header.isEmpty()); + } + + @Test + void givenMismatchedKeyCount_whenDetectHeader_thenReturnsEmpty() { + // Given + ObjectNode a = jsonNodeFactory.objectNode(); + a.put("id", 1); + a.put("name", "Ada"); + + ObjectNode b = jsonNodeFactory.objectNode(); + b.put("id", 2); // missing name key + + ArrayNode rows = jsonNodeFactory.arrayNode().add(a).add(b); + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertTrue(header.isEmpty()); + } + + @Test + void givenMissingHeaderKeyInLaterRow_whenDetectHeader_thenReturnsEmpty() { + // Given + ObjectNode a = jsonNodeFactory.objectNode(); + a.put("id", 1); + a.put("name", "Ada"); + + ObjectNode b = jsonNodeFactory.objectNode(); + b.put("id", 2); + b.put("age", 42); // same size but different key set (name missing) + + ArrayNode rows = jsonNodeFactory.arrayNode().add(a).add(b); + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertTrue(header.isEmpty()); + } + + @Test + void givenNonPrimitiveValue_whenDetectHeader_thenReturnsEmpty() { + // Given + ObjectNode a = jsonNodeFactory.objectNode(); + a.put("id", 1); + a.put("name", "Ada"); + + ObjectNode b = jsonNodeFactory.objectNode(); + b.put("id", 2); + b.set("name", jsonNodeFactory.objectNode()); // not a primitive + + ArrayNode rows = jsonNodeFactory.arrayNode().add(a).add(b); + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertTrue(header.isEmpty()); + } + + @Test + void givenUniformObjectsDifferentOrder_whenDetectHeader_thenReturnsHeaderKeys() { + // Given + ObjectNode a = jsonNodeFactory.objectNode(); + a.put("id", 1); + a.put("name", "Ada"); + + ObjectNode b = jsonNodeFactory.objectNode(); + b.put("name", "Bob"); // order swapped + b.put("id", 2); + + ArrayNode rows = jsonNodeFactory.arrayNode().add(a).add(b); + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertEquals(List.of("id", "name"), header); + } + + @Test + void givenUniformObjects_whenEncodeArrayAsTabular_thenWritesHeaderAndRows() { + // Given + ObjectNode a = jsonNodeFactory.objectNode(); + a.put("id", 1); + a.put("name", "Ada"); + + ObjectNode b = jsonNodeFactory.objectNode(); + b.put("id", 2); + b.put("name", "Bob"); + + ArrayNode rows = jsonNodeFactory.arrayNode().add(a).add(b); + List header = TabularArrayEncoder.detectTabularHeader(rows); + LineWriter writer = new LineWriter(options.indent()); + + // When + TabularArrayEncoder.encodeArrayOfObjectsAsTabular("users", rows, header, writer, 0, options); + + // Then + String expected = String.join("\n", + "users[2]{id,name}:", + " 1,Ada", + " 2,Bob"); + assertEquals(expected, writer.toString()); + } + + @Test + void givenHeaderAndRows_whenWriteTabularRows_thenWritesValuesWithIndent() { + // Given + ObjectNode a = jsonNodeFactory.objectNode(); + a.put("x", 10); + a.put("y", 20); + + ObjectNode b = jsonNodeFactory.objectNode(); + b.put("x", 11); + b.put("y", 21); + + ArrayNode rows = jsonNodeFactory.arrayNode().add(a).add(b); + List header = List.of("x", "y"); + LineWriter writer = new LineWriter(options.indent()); + + // When + TabularArrayEncoder.writeTabularRows(rows, header, writer, 2, options); + + // Then + String expected = String.join("\n", + " 10,20", + " 11,21"); + assertEquals(expected, writer.toString()); + } + + @Test + void testDetectTabularHeaderWithEmptyRow() { + // Given + ArrayNode rows = jsonNodeFactory.arrayNode(); + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertTrue(header.isEmpty()); + } + + @Test + void testDetectTabularHeaderWithNoneObjectAsFirstItem() { + // Given + ArrayNode rows = jsonNodeFactory.arrayNode(); + rows.add(1); + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertTrue(header.isEmpty()); + } + + @Test + void testDetectTabularHeaderWithEmptyObject() { + // Given + ArrayNode rows = jsonNodeFactory.arrayNode(); + ObjectNode a = jsonNodeFactory.objectNode(); + rows.add(a); + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertTrue(header.isEmpty()); + } + + @Test + void testDetectTabularHeaderWithSecondItemIsNotAnObject() { + // Given + ArrayNode rows = jsonNodeFactory.arrayNode(); + ObjectNode a = jsonNodeFactory.objectNode(); + rows.add(a).add(1); + + // When + List header = TabularArrayEncoder.detectTabularHeader(rows); + + // Then + assertTrue(header.isEmpty()); + } + + @Test + void testDetectTabularHeaderWithUnevenObjectInTheList() { + // Given + ObjectNode a = jsonNodeFactory.objectNode(); + a.put("x", 10); + a.put("y", 20); + + ObjectNode b = jsonNodeFactory.objectNode(); + b.put("x", 11); + + ArrayNode rows = jsonNodeFactory.arrayNode().add(a).add(b); + List header = List.of("x", "y"); + LineWriter writer = new LineWriter(options.indent()); + + // When + TabularArrayEncoder.writeTabularRows(rows, header, writer, 2, options); + + // Then + String expected = String.join("\n", + " 10,20", + " 11"); + assertEquals(expected, writer.toString()); + } + + @Test + void testDetectTabularHeaderWithUnevenObjectArrayMixInTheList() { + // Given + ObjectNode a = jsonNodeFactory.objectNode(); + a.put("x", 10); + a.put("y", 20); + + ArrayNode b = jsonNodeFactory.arrayNode(); + b.add(11); + b.add(12); + + ArrayNode rows = jsonNodeFactory.arrayNode().add(a).add(b); + List header = List.of("x", "y"); + LineWriter writer = new LineWriter(options.indent()); + + // When + TabularArrayEncoder.writeTabularRows(rows, header, writer, 2, options); + + // Then + String expected = String.join("\n", + " 10,20"); + assertEquals(expected, writer.toString()); + } +} diff --git a/src/test/java/dev/toonformat/jtoon/encoder/ValueEncoderTest.java b/src/test/java/dev/toonformat/jtoon/encoder/ValueEncoderTest.java new file mode 100644 index 0000000..e9495f8 --- /dev/null +++ b/src/test/java/dev/toonformat/jtoon/encoder/ValueEncoderTest.java @@ -0,0 +1,82 @@ +package dev.toonformat.jtoon.encoder; + +import dev.toonformat.jtoon.EncodeOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.node.JsonNodeFactory; +import tools.jackson.databind.node.ObjectNode; +import tools.jackson.databind.node.ArrayNode; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +import static org.junit.jupiter.api.Assertions.*; + +class ValueEncoderTest { + + private final JsonNodeFactory jsonNodeFactory = JsonNodeFactory.instance; + + @Test + @DisplayName("throws unsupported Operation Exception for calling the constructor") + void throwsOnConstructor() throws NoSuchMethodException { + // Given + final Constructor constructor = ValueEncoder.class.getDeclaredConstructor(); + constructor.setAccessible(true); + + // When + final InvocationTargetException thrown = + assertThrows(InvocationTargetException.class, constructor::newInstance); + + // Then + final Throwable cause = thrown.getCause(); + assertInstanceOf(UnsupportedOperationException.class, cause); + assertEquals("Utility class cannot be instantiated", cause.getMessage()); + } + + @Test + @DisplayName("given primitive JsonNode when encodeValue then returns encoded primitive") + void givenPrimitive_whenEncodeValue_thenReturnsEncodedPrimitive() { + // Given + var number = jsonNodeFactory.numberNode(42); + var options = EncodeOptions.DEFAULT; + + // When + String result = ValueEncoder.encodeValue(number, options); + + // Then + assertEquals("42", result); + } + + @Test + @DisplayName("given primitive array when encodeValue then writes inline array header and values") + void givenPrimitiveArray_whenEncodeValue_thenWritesInlineArray() { + // Given + ArrayNode array = jsonNodeFactory.arrayNode().add(1).add(2).add(3); + var options = EncodeOptions.DEFAULT; + + // When + String result = ValueEncoder.encodeValue(array, options); + + // Then + assertEquals("[3]: 1,2,3", result); + } + + @Test + @DisplayName("given simple object when encodeValue then writes key-value lines") + void givenObject_whenEncodeValue_thenWritesObjectLines() { + // Given + ObjectNode obj = jsonNodeFactory.objectNode(); + obj.put("a", 1); + obj.put("b", "x"); + var options = EncodeOptions.DEFAULT; + + // When + String result = ValueEncoder.encodeValue(obj, options); + + // Then + String expected = String.join("\n", + "a: 1", + "b: x"); + assertEquals(expected, result); + } +} diff --git a/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerTest.java b/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerTest.java index 8ded6d1..69f8c1c 100644 --- a/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerTest.java +++ b/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.BooleanNode; import tools.jackson.databind.node.DecimalNode; import tools.jackson.databind.node.DoubleNode; @@ -12,9 +13,13 @@ import tools.jackson.databind.node.IntNode; import tools.jackson.databind.node.LongNode; import tools.jackson.databind.node.NullNode; +import tools.jackson.databind.node.ObjectNode; import tools.jackson.databind.node.ShortNode; import tools.jackson.databind.node.StringNode; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.math.BigDecimal; import java.math.BigInteger; import java.time.Instant; @@ -25,6 +30,7 @@ import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; +import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; @@ -41,7 +47,7 @@ * JUnit 5 test class for JsonNormalizer utility. */ @Tag("unit") -public class JsonNormalizerTest { +class JsonNormalizerTest { @Nested @DisplayName("Null and JsonNode") @@ -373,7 +379,7 @@ void testUtilDate() { Date date = Date.from(Instant.parse("2023-10-15T14:30:45.123Z")); JsonNode result = JsonNormalizer.normalize(date); assertTrue(result.isString()); - assertEquals("2023-10-15T14:30:45.123Z", result.asString()); + assertEquals("2023-10-15", result.asString()); } } @@ -623,8 +629,8 @@ void testEmptyArrays() { @DisplayName("should handle nested arrays") void testNestedArrays() { Object[] array = { - new int[]{1, 2}, - new String[]{"a", "b"} + new int[]{1, 2}, + new String[]{"a", "b"} }; JsonNode result = JsonNormalizer.normalize(array); assertTrue(result.isArray()); @@ -758,8 +764,8 @@ void testNestedPojo() { @DisplayName("should handle collections of POJOs") void testCollectionOfPojos() { List pojos = List.of( - new SimplePojo("Alice", 25), - new SimplePojo("Bob", 30) + new SimplePojo("Alice", 25), + new SimplePojo("Bob", 30) ); JsonNode result = JsonNormalizer.normalize(pojos); assertTrue(result.isArray()); @@ -799,13 +805,13 @@ void testDeeplyNested() { @DisplayName("should handle mixed types in collections") void testMixedTypes() { List mixed = java.util.Arrays.asList( - 1, - "text", - true, - 3.14, - List.of(1, 2), - Map.of("key", "value"), - null + 1, + "text", + true, + 3.14, + List.of(1, 2), + Map.of("key", "value"), + null ); JsonNode result = JsonNormalizer.normalize(mixed); assertTrue(result.isArray()); @@ -852,5 +858,541 @@ void testMapWithNullValues() { assertTrue(result.get("key2").isNull()); } } + + @Test + @DisplayName("throws unsupported Operation Exception for calling the constructor") + void throwsOnConstructor() throws NoSuchMethodException { + final Constructor constructor = JsonNormalizer.class.getDeclaredConstructor(); + constructor.setAccessible(true); + + final InvocationTargetException thrown = + assertThrows(InvocationTargetException.class, constructor::newInstance); + + final Throwable cause = thrown.getCause(); + assertInstanceOf(UnsupportedOperationException.class, cause); + assertEquals("Utility class cannot be instantiated", cause.getMessage()); + } + + // Reflection helpers for invoking private static methods + private static Object invokePrivateStatic(String methodName, Class[] paramTypes, Object... args) throws Exception { + Method m = JsonNormalizer.class.getDeclaredMethod(methodName, paramTypes); + m.setAccessible(true); + return m.invoke(null, args); + } + + + @Nested + @DisplayName("tryNormalizePrimitive") + class TryNormalizePrimitive { + + @Test + @DisplayName("Given an Integer value, When tryNormalizePrimitive is called, Then an IntNode is returned") + void givenInteger_whenTryNormalizePrimitive_thenIntNode() throws Exception { + // Given + Integer input = 42; + + // When + Object result = invokePrivateStatic("tryNormalizePrimitive", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(IntNode.class, result); + assertEquals(42, ((JsonNode) result).asInt()); + } + + @Test + @DisplayName("Given an unsupported type, When tryNormalizePrimitive is called, Then null is returned") + void givenUnsupported_whenTryNormalizePrimitive_thenNull() throws Exception { + // Given + Object input = new Object(); + + // When + Object result = invokePrivateStatic("tryNormalizePrimitive", new Class[]{Object.class}, input); + + // Then + assertNull(result); + } + + @Test + @DisplayName("Given an String value, When tryNormalizePrimitive is called, Then an StringNode is returned") + void givenString_whenTryNormalizePrimitive_thenStringNode() throws Exception { + // Given + String input = "hello world"; + + // When + Object result = invokePrivateStatic("tryNormalizePrimitive", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(StringNode.class, result); + assertEquals("hello world", ((JsonNode) result).asString()); + } + + @Test + @DisplayName("Given an Boolean value, When tryNormalizePrimitive is called, Then an BooleanNode is returned") + void givenBoolean_whenTryNormalizePrimitive_thenBooleanNode() throws Exception { + // Given + Boolean input = Boolean.TRUE; + + // When + Object result = invokePrivateStatic("tryNormalizePrimitive", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(BooleanNode.class, result); + assertTrue(((JsonNode) result).asBoolean()); + } + + @Test + @DisplayName("Given an Long value, When tryNormalizePrimitive is called, Then an LongNode is returned") + void givenLong_whenTryNormalizePrimitive_thenLongNode() throws Exception { + // Given + Long input = Long.MAX_VALUE; + + // When + Object result = invokePrivateStatic("tryNormalizePrimitive", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(LongNode.class, result); + assertEquals(Long.MAX_VALUE, ((JsonNode) result).asLong()); + } + + @Test + @DisplayName("Given an Short value, When tryNormalizePrimitive is called, Then an ShortNode is returned") + void givenShort_whenTryNormalizePrimitive_thenShortNode() throws Exception { + // Given + Short input = Short.MAX_VALUE; + + // When + Object result = invokePrivateStatic("tryNormalizePrimitive", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(ShortNode.class, result); + assertEquals(Short.MAX_VALUE, ((JsonNode) result).asShort()); + } + + @Test + @DisplayName("Given an Byte value, When tryNormalizePrimitive is called, Then an ByteNode is returned") + void givenByte_whenTryNormalizePrimitive_thenByteNode() throws Exception { + // Given + byte input = 42; + + // When + Object result = invokePrivateStatic("tryNormalizePrimitive", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(IntNode.class, result); + assertEquals(42, ((JsonNode) result).intValue()); + } + } + + @Nested + @DisplayName("tryNormalizeBigNumber") + class TryNormalizeBigNumber { + + @Test + @DisplayName("Given BigInteger within long range, When tryNormalizeBigNumber is called, Then a LongNode is returned") + void givenBigIntegerInRange_whenTryNormalizeBigNumber_thenLongNode() throws Exception { + // Given + BigInteger input = BigInteger.valueOf(Long.MAX_VALUE); + + // When + Object result = invokePrivateStatic("tryNormalizeBigNumber", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(LongNode.class, result); + assertEquals(Long.MAX_VALUE, ((JsonNode) result).longValue()); + } + + @Test + @DisplayName("Given BigInteger outside long range, When tryNormalizeBigNumber is called, Then a StringNode is returned") + void givenBigIntegerOutOfRange_whenTryNormalizeBigNumber_thenStringNode() throws Exception { + // Given + BigInteger input = new BigInteger("99999999999999999999999999999999"); + + // When + Object result = invokePrivateStatic("tryNormalizeBigNumber", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(StringNode.class, result); + assertEquals(input.toString(), ((JsonNode) result).asString()); + } + + @Test + @DisplayName("Given BigDecimal value, When tryNormalizeBigNumber is called, Then a DecimalNode is returned") + void givenBigDecimal_whenTryNormalizeBigNumber_thenDecimalNode() throws Exception { + // Given + BigDecimal input = new BigDecimal("123.456"); + + // When + Object result = invokePrivateStatic("tryNormalizeBigNumber", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(DecimalNode.class, result); + assertEquals(input, ((JsonNode) result).decimalValue()); + } + + @Test + @DisplayName("Given non big-number type, When tryNormalizeBigNumber is called, Then null is returned") + void givenOther_whenTryNormalizeBigNumber_thenNull() throws Exception { + // Given + String input = "not-a-number"; + + // When + Object result = invokePrivateStatic("tryNormalizeBigNumber", new Class[]{Object.class}, input); + + // Then + assertNull(result); + } + } + + @Nested + @DisplayName("tryNormalizeTemporal") + class TryNormalizeTemporal { + + @Test + @DisplayName("Given LocalDate, When tryNormalizeTemporal is called, Then an ISO date StringNode is returned") + void givenLocalDate_whenTryNormalizeTemporal_thenIsoStringNode() throws Exception { + // Given + LocalDate input = LocalDate.of(2024, 2, 29); + + // When + Object result = invokePrivateStatic("tryNormalizeTemporal", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(StringNode.class, result); + assertEquals("2024-02-29", ((JsonNode) result).asString()); + } + + @Test + @DisplayName("Given non temporal type, When tryNormalizeTemporal is called, Then null is returned") + void givenOther_whenTryNormalizeTemporal_thenNull() throws Exception { + // Given + Object input = 10; + + // When + Object result = invokePrivateStatic("tryNormalizeTemporal", new Class[]{Object.class}, input); + + // Then + assertNull(result); + } + + @Test + @DisplayName("Given LocalDateTime, When tryNormalizeTemporal is called, Then an ISO date StringNode is returned") + void givenLocalDateTime_whenTryNormalizeTemporal_thenIsoStringNode() throws Exception { + // Given + LocalDateTime input = LocalDateTime.of(2024, 2, 29, 14, 45, 12); + + // When + Object result = invokePrivateStatic("tryNormalizeTemporal", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(StringNode.class, result); + assertEquals("2024-02-29T14:45:12", ((JsonNode) result).asString()); + } + + @Test + @DisplayName("Given LocalTime, When tryNormalizeTemporal is called, Then an ISO date StringNode is returned") + void givenLocalTime_whenTryNormalizeTemporal_thenIsoStringNode() throws Exception { + // Given + LocalTime input = LocalTime.of(14, 45, 12); + + // When + Object result = invokePrivateStatic("tryNormalizeTemporal", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(StringNode.class, result); + assertEquals("14:45:12", ((JsonNode) result).asString()); + } + + @Test + @DisplayName("Given ZoneDateTime, When tryNormalizeTemporal is called, Then an ISO date StringNode is returned") + void givenZoneDateTime_whenTryNormalizeTemporal_thenIsoStringNode() throws Exception { + // Given + ZonedDateTime input = ZonedDateTime.of(LocalDate.of(2025, 11, 26), LocalTime.of(15, 45), ZoneId.of("Europe/Berlin")); + + // When + Object result = invokePrivateStatic("tryNormalizeTemporal", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(StringNode.class, result); + assertEquals("2025-11-26T15:45:00+01:00[Europe/Berlin]", ((JsonNode) result).asString()); + } + + @Test + @DisplayName("Given OffsetDateTime, When tryNormalizeTemporal is called, Then an ISO date StringNode is returned") + void givenOffsetDateTime_whenTryNormalizeTemporal_thenIsoStringNode() throws Exception { + // Given + ZoneId zone = ZoneId.of("Europe/Berlin"); + ZoneOffset zoneOffSet = zone.getRules().getOffset(LocalDateTime.of(2025, 11, 26, 15, 45, 36)); + OffsetDateTime input = OffsetDateTime.of(LocalDate.of(2025, 11, 26), LocalTime.of(15, 45), zoneOffSet); + + // When + Object result = invokePrivateStatic("tryNormalizeTemporal", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(StringNode.class, result); + assertEquals("2025-11-26T15:45:00+01:00", ((JsonNode) result).asString()); + } + + @Test + @DisplayName("Given Instant, When tryNormalizeTemporal is called, Then an ISO date StringNode is returned") + void givenInstant_whenTryNormalizeTemporal_thenIsoStringNode() throws Exception { + // Given + ZoneId zone = ZoneId.of("Europe/Berlin"); + ZoneOffset zoneOffSet = zone.getRules().getOffset(LocalDateTime.of(2025, 11, 26, 15, 45, 36)); + Instant input = LocalDateTime.of(2025, 11, 26, 15, 45, 36).toInstant(zoneOffSet); + + // When + Object result = invokePrivateStatic("tryNormalizeTemporal", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(StringNode.class, result); + assertEquals("2025-11-26T14:45:36Z", ((JsonNode) result).asString()); + } + + @Test + @DisplayName("Given Date, When tryNormalizeTemporal is called, Then an ISO date StringNode is returned") + void givenDate_whenTryNormalizeTemporal_thenIsoStringNode() throws Exception { + // Given + Date input = new Date(1764362004); + + // When + Object result = invokePrivateStatic("tryNormalizeTemporal", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(StringNode.class, result); + assertEquals("1970-01-21", ((JsonNode) result).asString()); + } + } + + @Nested + @DisplayName("tryConvertToLong") + class TryConvertToLong { + + @Test + @DisplayName("Given whole double within long range, When tryConvertToLong is called, Then Optional with LongNode is returned") + void givenWholeDoubleInRange_whenTryConvertToLong_thenOptionalLongNode() throws Exception { + // Given + Double input = 1_000_000d; + + // When + Object result = invokePrivateStatic("tryConvertToLong", new Class[]{Double.class}, input); + + // Then + assertInstanceOf(Optional.class, result); + Optional opt = (Optional) result; + assertTrue(opt.isPresent()); + assertInstanceOf(LongNode.class, opt.get()); + assertEquals(1_000_000L, ((JsonNode) opt.get()).longValue()); + } + + @Test + @DisplayName("Given fractional double, When tryConvertToLong is called, Then Optional.empty is returned") + void givenFractionalDouble_whenTryConvertToLong_thenEmpty() throws Exception { + // Given + Double input = 3.14; + + // When + Object result = invokePrivateStatic("tryConvertToLong", new Class[]{Double.class}, input); + + // Then + assertInstanceOf(Optional.class, result); + assertTrue(((Optional) result).isEmpty()); + } + + @Test + @DisplayName("Given whole double outside long range (max), When tryConvertToLong is called, Then Optional.empty is returned") + void givenWholeDoubleOutOfRangeMax_whenTryConvertToLong_thenEmpty() throws Exception { + // Given + Double input = (double) Long.MAX_VALUE + 1000d; + + // When + Object result = invokePrivateStatic("tryConvertToLong", new Class[]{Double.class}, input); + + // Then + assertInstanceOf(Optional.class, result); + assertFalse(((Optional) result).isEmpty()); + } + + @Test + @DisplayName("Given whole double outside long range (min), When tryConvertToLong is called, Then Optional.empty is returned") + void givenWholeDoubleOutOfRangeMin_whenTryConvertToLong_thenEmpty() throws Exception { + // Given + Double input = (double) Long.MIN_VALUE - 1000d; + + // When + Object result = invokePrivateStatic("tryConvertToLong", new Class[]{Double.class}, input); + + // Then + assertInstanceOf(Optional.class, result); + assertFalse(((Optional) result).isEmpty()); + } + + @Test + @DisplayName("Given NonInteger, When tryConvertToLong is called, Then Optional.empty is returned") + void testNonIntegerValueReturnsEmpty_whenTryConvertToLong() throws Exception { + // Given + Double input = (double) 3.14; + + // When + Object result = invokePrivateStatic("tryConvertToLong", new Class[]{Double.class}, input); + + // Then + assertInstanceOf(Optional.class, result); + assertTrue(((Optional) result).isEmpty()); + } + + @Test + @DisplayName("Given Integer, When tryConvertToLong is called, Then Optional is returned") + void testIntegerValueReturnsOptional_whenTryConvertToLong() throws Exception { + // Given + Double input = (double) 10.0; + + // When + Object result = invokePrivateStatic("tryConvertToLong", new Class[]{Double.class}, input); + + // Then + assertInstanceOf(Optional.class, result); + assertFalse(((Optional) result).isEmpty()); + } + + @Test + @DisplayName("Given negative NonInteger, When tryConvertToLong is called, Then Optional.empty is returned") + void testNegativeNonIntegerValueReturnsEmptyWhenTryConvertToLong() throws Exception { + // Given + Double input = (double) -5.7; + + // When + Object result = invokePrivateStatic("tryConvertToLong", new Class[]{Double.class}, input); + + // Then + assertInstanceOf(Optional.class, result); + assertTrue(((Optional) result).isEmpty()); + } + + @Test + @DisplayName("Given negative Integer, When tryConvertToLong is called, Then Optional is returned") + void testNegativeIntegerValueReturnsOptionalWhenTryConvertToLong() throws Exception { + // Given + Double input = (double) -8.0; + + // When + Object result = invokePrivateStatic("tryConvertToLong", new Class[]{Double.class}, input); + + // Then + assertInstanceOf(Optional.class, result); + assertFalse(((Optional) result).isEmpty()); + } + } + + @Nested + @DisplayName("tryNormalizeCollection") + class TryNormalizeCollection { + + @Test + @DisplayName("Given List, When tryNormalizeCollection is called, Then ArrayNode is returned") + void givenList_whenTryNormalizeCollection_thenArrayNode() throws Exception { + // Given + List input = java.util.Arrays.asList(1, "two", true); + + // When + Object result = invokePrivateStatic("tryNormalizeCollection", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(ArrayNode.class, result); + ArrayNode array = (ArrayNode) result; + assertEquals(3, array.size()); + assertEquals(1, array.get(0).asInt()); + assertEquals("two", array.get(1).asString()); + assertTrue(array.get(2).asBoolean()); + } + + @Test + @DisplayName("Given Map, When tryNormalizeCollection is called, Then ObjectNode is returned") + void givenMap_whenTryNormalizeCollection_thenObjectNode() throws Exception { + // Given + Map input = new LinkedHashMap<>(); + input.put("a", 1); + input.put("b", "two"); + + // When + Object result = invokePrivateStatic("tryNormalizeCollection", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(ObjectNode.class, result); + ObjectNode object = (ObjectNode) result; + assertEquals(1, object.get("a").asInt()); + assertEquals("two", object.get("b").asString()); + } + + @Test + @DisplayName("Given non-collection, When tryNormalizeCollection is called, Then null is returned") + void givenOther_whenTryNormalizeCollection_thenNull() throws Exception { + // Given + Object input = 10.0; + + // When + Object result = invokePrivateStatic("tryNormalizeCollection", new Class[]{Object.class}, input); + + // Then + assertNull(result); + } + } + + @Nested + @DisplayName("normalizeCollection") + class NormalizeCollection { + + @Test + @DisplayName("Given mixed-type list, When normalizeCollection is called, Then items are normalized in an ArrayNode") + void givenMixedList_whenNormalizeCollection_thenArrayNode() throws Exception { + // Given + List input = java.util.Arrays.asList(1, 2L, 3.0, "four"); + + // When + Object result = invokePrivateStatic("normalizeCollection", new Class[]{Collection.class}, input); + + // Then + assertInstanceOf(ArrayNode.class, result); + ArrayNode array = (ArrayNode) result; + assertEquals(4, array.size()); + assertEquals(1, array.get(0).asInt()); + assertEquals(2L, array.get(1).asLong()); + assertEquals(3.0, array.get(2).asDouble()); + assertEquals("four", array.get(3).asString()); + } + + @Test + @DisplayName("Given empty list, When normalizeCollection is called, Then an empty ArrayNode is returned") + void givenEmptyList_whenNormalizeCollection_thenEmptyArrayNode() throws Exception { + // Given + List input = java.util.Collections.emptyList(); + + // When + Object result = invokePrivateStatic("normalizeCollection", new Class[]{Collection.class}, input); + + // Then + assertInstanceOf(ArrayNode.class, result); + assertEquals(0, ((ArrayNode) result).size()); + } + } + + @Nested + @DisplayName("normalizeArray") + class NormalizeArray { + + @Test + @DisplayName("Given Object, When normalizeArray is called, Then ArrayNode get return") + void givenException_whenTryNormalizePojo_thenNullNode() throws Exception { + // Given + Object input = new Object(); + + // When + Object result = invokePrivateStatic("normalizeArray", new Class[]{Object.class}, input); + + // Then + assertInstanceOf(ArrayNode.class, result); + + + } + } } diff --git a/src/test/java/dev/toonformat/jtoon/util/StringEscaperTest.java b/src/test/java/dev/toonformat/jtoon/util/StringEscaperTest.java index a3e4c59..b4d81f7 100644 --- a/src/test/java/dev/toonformat/jtoon/util/StringEscaperTest.java +++ b/src/test/java/dev/toonformat/jtoon/util/StringEscaperTest.java @@ -225,6 +225,31 @@ void testNoEscapeSequences() { void testUnknownEscapeSequences() { assertEquals("ax", StringEscaper.unescape("\\ax")); } + + @Test + void unquotesValueWhenStartsAndEndsWithQuote() { + assertEquals("abc", StringEscaper.unescape("\"abc\"")); + } + @Test + void unescapesBackslashSequences() { + assertEquals("a\"b", StringEscaper.unescape("a\\\"b")); + } + + @Test + void unescapesMultipleCharacters() { + assertEquals("a\nb\tc", StringEscaper.unescape("a\\nb\\tc")); + } + + @Test + void handlesTrailingBackslashCorrectly() { + // trailing \ will set escaped=true but there is no next char → nothing appended + assertEquals("abc", StringEscaper.unescape("abc\\")); + } + + @Test + void handlesDoubleBackslashCorrectly() { + assertEquals("a\\b", StringEscaper.unescape("a\\\\b")); + } } @Test @@ -240,4 +265,33 @@ void throwsOnConstructor() throws NoSuchMethodException { assertInstanceOf(UnsupportedOperationException.class, cause); assertEquals("Utility class cannot be instantiated", cause.getMessage()); } + + @Test + void testingValidateString_WithNull() { + // Given + String input = null; + // When + StringEscaper.validateString(input); + // Then + + } + @Test + void testingValidateString_WithEmptyString() { + // Given + String input = ""; + // When + StringEscaper.validateString(input); + // Then + + } + @Test + void testingValidateString_WithWildStringToThrowsException() { + // Given + String input = "\"te\\st\""; + // When // Then + assertThrows(IllegalArgumentException.class, + ()->{ + StringEscaper.validateString(input); + }); + } } diff --git a/src/test/java/dev/toonformat/jtoon/util/StringValidatorTest.java b/src/test/java/dev/toonformat/jtoon/util/StringValidatorTest.java index e056c45..90a3a40 100644 --- a/src/test/java/dev/toonformat/jtoon/util/StringValidatorTest.java +++ b/src/test/java/dev/toonformat/jtoon/util/StringValidatorTest.java @@ -19,7 +19,7 @@ * Tests validation logic for safe unquoted strings and keys in TOON format. */ @Tag("unit") -public class StringValidatorTest { +class StringValidatorTest { @Nested @DisplayName("isSafeUnquoted - Basic Cases") @@ -409,5 +409,42 @@ void throwsOnConstructor() throws NoSuchMethodException { assertInstanceOf(UnsupportedOperationException.class, cause); assertEquals("Utility class cannot be instantiated", cause.getMessage()); } + + @Test + void returnsFalseForStringWithoutQuotesOrBackslash() { + assertFalse(StringValidator.containsQuotesOrBackslash("abc")); + } + + @Test + void detectsDoubleQuote() { + assertTrue(StringValidator.containsQuotesOrBackslash("a\"b")); + } + + @Test + void detectsBackslash() { + assertTrue(StringValidator.containsQuotesOrBackslash("a\\b")); + } + + @Test + void detectsBoth() { + assertTrue(StringValidator.containsQuotesOrBackslash("x\"y\\z")); + } + + @Test + void detectsQuoteAtStart() { + assertTrue(StringValidator.containsQuotesOrBackslash("\"abc")); + assertTrue(StringValidator.containsQuotesOrBackslash("\\abc")); + assertTrue(StringValidator.containsQuotesOrBackslash("x\"y\\z")); + } + + @Test + void detectsBackslashAtEnd() { + assertTrue(StringValidator.containsQuotesOrBackslash("abc\\")); + } + + @Test + void emptyStringReturnsFalse() { + assertFalse(StringValidator.containsQuotesOrBackslash("")); + } } diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..f0ee14f --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores