You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tapestry.apache.org by th...@apache.org on 2026/10/17 13:34:35 UTC

(tapestry-5) 01/02: TAP5-2837: Improve JSON serialization performance (#67)

This is an automated email from the ASF dual-hosted git repository.

thiagohp pushed a commit to branch 5.9.x
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git

commit 1175970e98e1cf47580ae2a27278f85022c21523
Author: Ben Weidig <be...@users.noreply.github.com>
AuthorDate: Sat Oct 10 12:31:27 2026 +0200

    TAP5-2837: Improve JSON serialization performance (#67)
---
 .../org/apache/tapestry5/json/CompactSession.java  |  23 +-
 .../org/apache/tapestry5/json/JSONCollection.java  |  36 +-
 .../java/org/apache/tapestry5/json/JSONObject.java | 200 ++++-
 .../org/apache/tapestry5/json/JSONPrintSink.java   |  92 +++
 .../org/apache/tapestry5/json/JSONStringer.java    |   3 +
 .../org/apache/tapestry5/json/JSONTokener.java     |   4 +-
 .../apache/tapestry5/json/PrettyPrintSession.java  |  41 +-
 .../org/apache/tapestry5/json/package-info.java    |   4 +-
 .../org/apache/tapestry5/json/JSONObjectTest.java  | 896 +++++++++++++++++++++
 9 files changed, 1241 insertions(+), 58 deletions(-)

diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java
index 8eced6925..55127a23a 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java
@@ -1,4 +1,4 @@
-// Copyright 2010 The Apache Software Foundation
+// Copyright 2010, 2026 The Apache Software Foundation
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -18,16 +18,21 @@ import java.io.PrintWriter;
 
 /**
  * Prints the JSON content compactly, with no indentation or extra whitespace.
- * 
+ *
  * @since 5.2.0
  */
 class CompactSession implements JSONPrintSession
 {
-    private final PrintWriter writer;
+    private final JSONPrintSink out;
+
+    CompactSession(StringBuilder out)
+    {
+        this.out = new StringBuilderPrintSink(out);
+    }
 
     public CompactSession(PrintWriter writer)
     {
-        this.writer = writer;
+        this.out = new WriterPrintSink(writer);
     }
 
     @Override
@@ -51,7 +56,7 @@ class CompactSession implements JSONPrintSession
     @Override
     public JSONPrintSession print(String value)
     {
-        writer.print(value);
+        out.append(value);
 
         return this;
     }
@@ -59,13 +64,17 @@ class CompactSession implements JSONPrintSession
     @Override
     public JSONPrintSession printQuoted(String value)
     {
-        return print(JSONObject.quote(value));
+        out.append('"');
+        JSONObject.escapeInto(out, value);
+        out.append('"');
+
+        return this;
     }
 
     @Override
     public JSONPrintSession printSymbol(char symbol)
     {
-        writer.print(symbol);
+        out.append(symbol);
 
         return this;
     }
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java
index c5ee1082c..0615bc47a 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java
@@ -12,7 +12,6 @@
 
 package org.apache.tapestry5.json;
 
-import java.io.CharArrayWriter;
 import java.io.PrintWriter;
 import java.io.Serializable;
 
@@ -38,16 +37,13 @@ public abstract class JSONCollection implements Serializable
     @Override
     public String toString()
     {
-        CharArrayWriter caw = new CharArrayWriter();
-        PrintWriter pw = new PrintWriter(caw);
+        StringBuilder out = new StringBuilder(estimateCapacity());
 
-        JSONPrintSession session = new PrettyPrintSession(pw);
+        JSONPrintSession session = new PrettyPrintSession(out);
 
         print(session);
 
-        pw.close();
-
-        return caw.toString();
+        return out.toString();
     }
 
     /**
@@ -68,16 +64,32 @@ public abstract class JSONCollection implements Serializable
      */
     public String toCompactString()
     {
-        CharArrayWriter caw = new CharArrayWriter();
-        PrintWriter pw = new PrintWriter(caw);
+        StringBuilder out = new StringBuilder(estimateCapacity());
 
-        print(pw);
+        JSONPrintSession session = new CompactSession(out);
 
-        pw.close();
+        print(session);
 
-        return caw.toString();
+        return out.toString();
+    }
+
+    /**
+     * A cheap starting capacity for the {@link StringBuilder} backing {@link #toString()} /
+     * {@link #toCompactString()}, to avoid repeated grow-and-copy cycles for non-trivial trees.
+     * Scales with the top-level collection's element count only; recursing into nested
+     * collections isn't worth the extra bookkeeping for a rough estimate.
+     */
+    private int estimateCapacity()
+    {
+        return Math.max(256, size() * 32);
     }
 
+    /**
+     * The number of top-level entries (name/value pairs for {@link JSONObject}, elements for
+     * {@link JSONArray}), used only to estimate a starting buffer capacity.
+     */
+    abstract int size();
+
     /**
      * Prints the JSONObject to the write (compactly or not).
      * 
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java
index 321085ed5..b7f034549 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java
@@ -775,14 +775,154 @@ public final class JSONObject extends JSONCollection implements Map<String, Obje
         if (data == null) {
             return "\"\"";
         }
-        try {
-            JSONStringer stringer = new JSONStringer();
-            stringer.open(JSONStringer.Scope.NULL, "");
-            stringer.string(data);
-            stringer.close(JSONStringer.Scope.NULL, JSONStringer.Scope.NULL, "");
-            return stringer.toString();
-        } catch (RuntimeException e) {
-            throw new AssertionError();
+
+        StringBuilder out = new StringBuilder(data.length() + 8);
+        out.append('"');
+        escapeInto(out, data);
+        out.append('"');
+        return out.toString();
+    }
+
+    private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray();
+
+    /**
+     * Appends the escaped (but not quoted) form of {@code value} to {@code out}: the same
+     * character escaping rules as {@link #quote(String)}, without allocating an intermediate
+     * String or wrapping quotes.
+     * 
+     * <p>This is the direct-to-{@link StringBuilder} fast path used by {@code quote()} itself,
+     * which always targets a StringBuilder and is called often enough (once per printed string/key)
+     * that the {@link JSONPrintSink} indirection used by {@link #escapeInto(JSONPrintSink, String)}
+     * measurably costs more than it's worth here. See {@link #escapeInto(JSONPrintSink, String)}
+     * for the print-session variant.
+     */
+    static void escapeInto(StringBuilder out, String value) {
+        char currentChar = 0;
+
+        for (int i = 0, length = value.length(); i < length; i++) {
+            char previousChar = currentChar;
+            currentChar = value.charAt(i);
+
+            /*
+             * From RFC 4627, "All Unicode characters may be placed within the
+             * quotation marks except for the characters that must be escaped:
+             * quotation mark, reverse solidus, and the control characters
+             * (U+0000 through U+001F)."
+             */
+            switch (currentChar) {
+                case '"':
+                case '\\':
+                    out.append('\\').append(currentChar);
+                    break;
+
+                case '/':
+                    // it makes life easier for HTML embedding of javascript if we escape </ sequences
+                    if (previousChar == '<') {
+                        out.append('\\');
+                    }
+                    out.append(currentChar);
+                    break;
+
+                case '\t':
+                    out.append("\\t");
+                    break;
+
+                case '\b':
+                    out.append("\\b");
+                    break;
+
+                case '\n':
+                    out.append("\\n");
+                    break;
+
+                case '\r':
+                    out.append("\\r");
+                    break;
+
+                case '\f':
+                    out.append("\\f");
+                    break;
+
+                default:
+                    if (currentChar <= 0x1F || (currentChar >= 0x0080 && currentChar < 0x00a0) || (currentChar >= 0x2000 && currentChar < 0x2100)) {
+                        out.append('\\').append('u')
+                           .append(HEX_DIGITS[(currentChar >> 12) & 0xF])
+                           .append(HEX_DIGITS[(currentChar >> 8) & 0xF])
+                           .append(HEX_DIGITS[(currentChar >> 4) & 0xF])
+                           .append(HEX_DIGITS[currentChar & 0xF]);
+                    } else {
+                        out.append(currentChar);
+                    }
+                    break;
+            }
+        }
+    }
+
+    /**
+     * Same escaping rules as {@link #escapeInto(StringBuilder, String)}, targeting a
+     * {@link JSONPrintSink} instead.
+     * 
+     * <p>Used by the print sessions, whose destination varies between an in-memory
+     * {@link StringBuilder} ({@code toString()}/{@code toCompactString()}) and a
+     * caller-supplied {@link java.io.PrintWriter} ({@code print(PrintWriter, boolean)}),
+     * so they can escape directly into whichever one they were constructed with.
+     * 
+     * <p>Note: Having dedicated methods for the types instead of a classifying mechanism
+     * keeps the call site monomorphic for the JVM, allowing for greater optimization
+     * potential.
+     */
+    static void escapeInto(JSONPrintSink out, String value) {
+        char currentChar = 0;
+
+        for (int i = 0, length = value.length(); i < length; i++) {
+            char previousChar = currentChar;
+            currentChar = value.charAt(i);
+
+            switch (currentChar) {
+                case '"':
+                case '\\':
+                    out.append('\\').append(currentChar);
+                    break;
+
+                case '/':
+                    if (previousChar == '<') {
+                        out.append('\\');
+                    }
+                    out.append(currentChar);
+                    break;
+
+                case '\t':
+                    out.append("\\t");
+                    break;
+
+                case '\b':
+                    out.append("\\b");
+                    break;
+
+                case '\n':
+                    out.append("\\n");
+                    break;
+
+                case '\r':
+                    out.append("\\r");
+                    break;
+
+                case '\f':
+                    out.append("\\f");
+                    break;
+
+                default:
+                    if (currentChar <= 0x1F || (currentChar >= 0x0080 && currentChar < 0x00a0) || (currentChar >= 0x2000 && currentChar < 0x2100)) {
+                        out.append('\\').append('u')
+                           .append(HEX_DIGITS[(currentChar >> 12) & 0xF])
+                           .append(HEX_DIGITS[(currentChar >> 8) & 0xF])
+                           .append(HEX_DIGITS[(currentChar >> 4) & 0xF])
+                           .append(HEX_DIGITS[currentChar & 0xF]);
+                    } else {
+                        out.append(currentChar);
+                    }
+                    break;
+            }
         }
     }
 
@@ -826,7 +966,6 @@ public final class JSONObject extends JSONCollection implements Map<String, Obje
         session.printSymbol('}');
     }
 
-
     /**
      * Prints a value (a JSONArray or JSONObject, or a value stored in an array or object) using
      * the session.
@@ -835,48 +974,61 @@ public final class JSONObject extends JSONCollection implements Map<String, Obje
      */
     static void printValue(JSONPrintSession session, Object value)
     {
+        // Orderd by (assumed) most common frequency in JSON
 
+        // Null Check: Must be first.
         if (value == null || value == NULL)
         {
             session.print("null");
             return;
         }
-        if (value instanceof JSONObject)
-        {
-            ((JSONObject) value).print(session);
-            return;
-        }
 
-        if (value instanceof JSONArray)
+        // String: Explicitly checking here prevents the most common 
+        // JSON type from falling through all other checks.
+        if (value instanceof String)
         {
-            ((JSONArray) value).print(session);
+            session.printQuoted((String) value);
             return;
         }
 
-        if (value instanceof JSONString)
+        // Number: Highly common in JSON payloads.
+        if (value instanceof Number)
         {
-            String printValue = ((JSONString) value).toJSONString();
-
+            String printValue = numberToString((Number) value);
             session.print(printValue);
-
             return;
         }
 
-        if (value instanceof Number)
+        // JSONObject: The primary structural container.
+        if (value instanceof JSONObject)
         {
-            String printValue = numberToString((Number) value);
-            session.print(printValue);
+            ((JSONObject) value).print(session);
             return;
         }
 
+        // Boolean: Common, but usually fewer instances per payload than numbers.
         if (value instanceof Boolean)
         {
             session.print(value.toString());
+            return;
+        }
 
+        // JSONArray: Generally fewer arrays than objects in nested structures.
+        if (value instanceof JSONArray)
+        {
+            ((JSONArray) value).print(session);
+            return;
+        }
+
+        // JSONString: Kept near the bottom assuming it is a custom wrapper.
+        if (value instanceof JSONString)
+        {
+            String printValue = ((JSONString) value).toJSONString();
+            session.print(printValue);
             return;
         }
 
-        // Otherwise it really should just be a string. Nothing else can go in.
+        // Fallback for any other unexpected objects
         session.printQuoted(value.toString());
     }
 
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONPrintSink.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONPrintSink.java
new file mode 100644
index 000000000..ccd843af9
--- /dev/null
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONPrintSink.java
@@ -0,0 +1,92 @@
+// Copyright 2026 The Apache Software Foundation
+// 
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.json;
+
+import java.io.PrintWriter;
+
+/**
+ * The destination a {@link JSONPrintSession} (and {@link JSONObject#escapeInto}) writes
+ * characters to: either an in-memory buffer (for {@code toString()}/{@code toCompactString()})
+ * or a caller-supplied {@link PrintWriter} (for {@code print(PrintWriter, boolean)}).
+ *
+ * <p>Deliberately narrower than {@link Appendable}: neither implementation below can actually throw
+ * {@link java.io.IOException}, so this avoids forcing checked-exception handling onto every
+ * append call in the hot print path.
+ */
+interface JSONPrintSink
+{
+    JSONPrintSink append(String value);
+
+    JSONPrintSink append(char value);
+}
+
+/**
+ * Sink backed by an in-memory {@link StringBuilder}, used to build a {@code String} directly
+ * without an intermediate {@code CharArrayWriter}/{@code PrintWriter} pair.
+ */
+class StringBuilderPrintSink implements JSONPrintSink
+{
+    private final StringBuilder out;
+
+    StringBuilderPrintSink(StringBuilder out)
+    {
+        this.out = out;
+    }
+
+    @Override
+    public JSONPrintSink append(String value)
+    {
+        out.append(value);
+
+        return this;
+    }
+
+    @Override
+    public JSONPrintSink append(char value)
+    {
+        out.append(value);
+
+        return this;
+    }
+}
+
+/**
+ * Sink backed by a caller-supplied {@link PrintWriter}, used by {@code print(PrintWriter, boolean)}.
+ */
+class WriterPrintSink implements JSONPrintSink
+{
+    private final PrintWriter writer;
+
+    WriterPrintSink(PrintWriter writer)
+    {
+        this.writer = writer;
+    }
+
+    @Override
+    public JSONPrintSink append(String value)
+    {
+        writer.print(value);
+
+        return this;
+    }
+
+    @Override
+    public JSONPrintSink append(char value)
+    {
+        writer.write(value);
+
+        return this;
+    }
+}
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java
index 861263574..d49b6c3a1 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java
@@ -57,7 +57,10 @@ import java.util.List;
  * self-use by overrideable methods is not specified. See <i>Effective Java</i>
  * Item 17, "Design and Document or inheritance or else prohibit it" for further
  * information.
+ * 
+ * @deprecated in 5.10.0 without a replacement.
  */
+@Deprecated
 class JSONStringer {
 
     /**
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java
index e0c4dac3c..225c8c26c 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java
@@ -476,14 +476,14 @@ class JSONTokener {
                 case ',':
                 case ';':
                     /* A separator without a value first means "null". */
-                    result.put(JSONObject.NULL);
+                    result.add(JSONObject.NULL);
                     hasTrailingSeparator = true;
                     continue;
                 default:
                     pos--;
             }
 
-            result.put(nextValue(null));
+            result.add(nextValue(null));
 
             switch (nextCleanInternal()) {
                 case ']':
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java
index 8f0c5f31b..729f0c39b 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java
@@ -1,4 +1,4 @@
-// Copyright 2010 The Apache Software Foundation
+// Copyright 2010, 2026 The Apache Software Foundation
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -18,12 +18,12 @@ import java.io.PrintWriter;
 
 /**
  * Used to pretty-print JSON content, with a customizable indentation.
- * 
+ *
  * @since 5.2.0
  */
 class PrettyPrintSession implements JSONPrintSession
 {
-    private final PrintWriter writer;
+    private final JSONPrintSink out;
 
     private final String indentString;
 
@@ -36,10 +36,16 @@ class PrettyPrintSession implements JSONPrintSession
 
     private Position position = Position.MARGIN;
 
+    /** Defaults the indentation to be two spaces per indentation level. */
+    PrettyPrintSession(StringBuilder out)
+    {
+        this(new StringBuilderPrintSink(out), "  ");
+    }
+
     /** Defaults the indentation to be two spaces per indentation level. */
     public PrettyPrintSession(PrintWriter writer)
     {
-        this(writer, "  ");
+        this(new WriterPrintSink(writer), "  ");
     }
 
     /**
@@ -50,7 +56,12 @@ class PrettyPrintSession implements JSONPrintSession
      */
     public PrettyPrintSession(PrintWriter writer, String indentString)
     {
-        this.writer = writer;
+        this(new WriterPrintSink(writer), indentString);
+    }
+
+    private PrettyPrintSession(JSONPrintSink out, String indentString)
+    {
+        this.out = out;
         this.indentString = indentString;
     }
 
@@ -67,7 +78,7 @@ class PrettyPrintSession implements JSONPrintSession
     {
         if (position != Position.MARGIN)
         {
-            writer.write('\n');
+            out.append('\n');
             position = Position.MARGIN;
         }
 
@@ -87,7 +98,7 @@ class PrettyPrintSession implements JSONPrintSession
         if (position == Position.MARGIN)
         {
             for (int i = 0; i < indentLevel; i++)
-                writer.print(indentString);
+                out.append(indentString);
 
             position = Position.INDENTED;
         }
@@ -97,7 +108,7 @@ class PrettyPrintSession implements JSONPrintSession
     {
         if (position == Position.CONTENT)
         {
-            writer.print(' ');
+            out.append(' ');
         }
     }
 
@@ -113,7 +124,7 @@ class PrettyPrintSession implements JSONPrintSession
     {
         prepareToPrint();
 
-        writer.print(value);
+        out.append(value);
 
         position = Position.CONTENT;
 
@@ -123,7 +134,15 @@ class PrettyPrintSession implements JSONPrintSession
     @Override
     public JSONPrintSession printQuoted(String value)
     {
-        return print(JSONObject.quote(value));
+        prepareToPrint();
+
+        out.append('"');
+        JSONObject.escapeInto(out, value);
+        out.append('"');
+
+        position = Position.CONTENT;
+
+        return this;
     }
 
     @Override
@@ -134,7 +153,7 @@ class PrettyPrintSession implements JSONPrintSession
         if (symbol != ',')
             addSep();
 
-        writer.print(symbol);
+        out.append(symbol);
 
         return this;
     }
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java
index 464082c05..2d5a6715a 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java
@@ -13,7 +13,7 @@
 /**
  * Repackaged, improved (and tested) version of code originally https://github.com/tdunning/open-json
  *
- * Starting in release 5.4, {@link org.apache.tapestry5.json.JSONObject} and {@link org.apache.tapestry5.json.JSONArray}
+ * Starting in release 5.4, {@link org.apache.tapestry5.json.improved.JSONObject} and {@link org.apache.tapestry5.json.improved.JSONArray}
  * are serializable.
  */
-package org.apache.tapestry5.json;
+package org.apache.tapestry5.json.improved;
diff --git a/tapestry-json/src/test/java/org/apache/tapestry5/json/JSONObjectTest.java b/tapestry-json/src/test/java/org/apache/tapestry5/json/JSONObjectTest.java
new file mode 100644
index 000000000..64689de9d
--- /dev/null
+++ b/tapestry-json/src/test/java/org/apache/tapestry5/json/JSONObjectTest.java
@@ -0,0 +1,896 @@
+// Copyright 2025, 2026 The Apache Software Foundation
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.json;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.CharArrayWriter;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.PrintWriter;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.tapestry5.json.exceptions.JSONInvalidTypeException;
+import org.apache.tapestry5.json.exceptions.JSONSyntaxException;
+import org.apache.tapestry5.json.exceptions.JSONTypeMismatchException;
+import org.apache.tapestry5.json.exceptions.JSONValueNotFoundException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class JSONObjectTest {
+
+    @Test
+    void copyAnExistingJSONObject() {
+
+        JSONObject master = new JSONObject("fred", "flintstone", "barney", "rubble");
+
+        JSONObject emptyCopy = new JSONObject(master);
+
+        assertTrue(emptyCopy.keys().isEmpty());
+
+        JSONObject limitedCopy = new JSONObject(master, "fred");
+
+        assertEquals(1, limitedCopy.keys().size());
+        assertEquals("flintstone", limitedCopy.get("fred"));
+
+        JSONObject fullCopy = new JSONObject(master, "fred", "barney");
+
+        assertEquals("{\"fred\":\"flintstone\",\"barney\":\"rubble\"}", fullCopy.toCompactString());
+    }
+
+    @Test
+    void copyAllPropertiesOfJSONObject() {
+
+        JSONObject master = new JSONObject("fred", "flintstone", "barney", "rubble");
+
+        JSONObject fullCopy = master.copy();
+
+        assertEquals(master, fullCopy);
+
+        master.put("wilma", "flintstone");
+
+        assertNotEquals(master, fullCopy);
+    }
+
+    @Test
+    void unknownKeysWhenCopyingAJSONObjectAreIgnored() {
+
+        JSONObject master = new JSONObject("fred", "flintstone", "barney", "rubble");
+
+        JSONObject copy = new JSONObject(master, "fred", "wilma");
+
+        assertEquals(1, copy.keys().size());
+        assertEquals("flintstone", copy.get("fred"));
+    }
+
+    @Test
+    void getWithUnknownKeyIsAnException() {
+
+        JSONObject master = new JSONObject("fred", "flintstone");
+
+        JSONValueNotFoundException e = assertThrows(JSONValueNotFoundException.class, () -> {
+            master.get("barney");
+        });
+
+        assertTrue(e.getMessage().startsWith("JSONObject[\"barney\"] is not found."));
+        assertTrue(e.getMessage().contains("Required: ANY"));
+    }
+
+    @Test
+    void getOrDefaultReturnsDefaultValueIfNotFound() {
+
+        JSONObject master = new JSONObject("fred", "flintstone");
+
+        Object result = master.getOrDefault("barney", "gumble");
+
+        assertEquals("gumble", result);
+    }
+
+    @Test
+    void getOrDefaultReturnsValueIfFound() {
+
+        JSONObject master = new JSONObject("fred", "flintstone");
+
+        Object result = master.getOrDefault("fred", "other");
+
+        assertEquals("flintstone", result);
+    }
+
+    // Data provider for ensureThatStoredValueIsExpected
+    static Stream<Arguments> storedValueProvider() {
+        return Stream.of(
+                Arguments.of("true", true, "Boolean"),
+                Arguments.of("TRUE", true, "Boolean"),
+                Arguments.of("false", false, "Boolean"),
+                Arguments.of("FALSE", false, "Boolean"),
+                Arguments.of(Boolean.TRUE, true, "Boolean"),
+                Arguments.of(Boolean.FALSE, false, "Boolean"),
+                Arguments.of(3.5d, 3.5d, "Double"),
+                Arguments.of(1_000L, 1_000d, "Double"),
+                Arguments.of("-101.7", -101.7d, "Double"),
+                Arguments.of("3", 3, "Int"),
+                Arguments.of(97L, 97, "Int"),
+                Arguments.of("-8.76", -8, "Int"),
+                Arguments.of(92d, 92L, "Long"),
+                Arguments.of("-200", -200L, "Long"),
+                Arguments.of(123, "123", "String")
+        );
+    }
+
+    @ParameterizedTest(name = "ensure that stored value {0} ({argumentsWithNames}) is {2} {1}")
+    @MethodSource("storedValueProvider")
+    void ensureThatStoredValueIsExpected(Object value, Object expected, String expectedType) {
+
+        JSONObject object = new JSONObject().put("key", value);
+        Function<String, Object> fn;
+        switch (expectedType) {
+            case "Boolean":
+                fn = object::getBoolean;
+                break;
+            case "Double":
+                fn = object::getDouble;
+                break;
+            case "Int":
+                fn = object::getInt;
+                break;
+            case "Long":
+                fn = object::getLong;
+                break;
+            case "String":
+                fn = object::getString;
+                break;
+            default:
+                throw new IllegalArgumentException("Unknown expected type: " + expectedType);
+        }
+
+        Object result = fn.apply("key");
+
+        assertEquals(expected, result);
+    }
+
+
+    static Stream<Arguments> doubleToStringProvider() {
+        return Stream.of(
+                Arguments.of(3d, "3"),
+                Arguments.of(-22.5d, "-22.5"),
+                Arguments.of(0d, "0"),
+                Arguments.of(Double.NaN, "null"),
+                Arguments.of(Double.NEGATIVE_INFINITY, "null"),
+                Arguments.of(Double.POSITIVE_INFINITY, "null")
+        );
+    }
+
+    @ParameterizedTest(name = "conversion of double {0} to string should be ''{1}''")
+    @MethodSource("doubleToStringProvider")
+    void doubleToStringConversion(double input, String expected) {
+
+        assertEquals(expected, JSONObject.doubleToString(input));
+    }
+
+    @Test
+    void exceptionWhenGetBooleanOnAValueThatIsNotABoolean() {
+
+        JSONObject object = new JSONObject().put("key", 37);
+
+        JSONTypeMismatchException e = assertThrows(JSONTypeMismatchException.class, () -> {
+            object.getBoolean("key");
+        });
+
+        assertTrue(e.getMessage().startsWith("JSONObject[\"key\"] is not a BOOLEAN."));
+    }
+
+    @Test
+    void accumulateSimpleValues() {
+
+        JSONObject object = new JSONObject();
+        String key = "acckey";
+
+        object.accumulate(key, "alpha");
+
+        assertEquals("{\"acckey\":\"alpha\"}", object.toCompactString());
+
+        object.accumulate(key, "beta");
+        object.accumulate(key, "gamma");
+
+        assertEquals("{\"acckey\":[\"alpha\",\"beta\",\"gamma\"]}", object.toCompactString());
+        assertEquals(3, object.getJSONArray(key).size());
+    }
+
+    @Test
+    void accumulateWhenTheStoredValueIsAlreadyAnArrayJustAppendsTheValue() {
+
+        JSONObject object = new JSONObject();
+
+        object.accumulate("key", new JSONArray("fred", "barney"));
+
+        assertEquals("{\"key\":[\"fred\",\"barney\"]}", object.toCompactString());
+
+        object.accumulate("key", "wilma");
+
+        assertEquals("{\"key\":[\"fred\",\"barney\",\"wilma\"]}", object.toCompactString());
+    }
+
+    @Test
+    void canCreateAJSONObjectFromAJSONString() {
+
+        JSONObject object = new JSONObject("{ fred: \"flintstone\", caveman: true, friends: [\"barney\"] }");
+
+        assertEquals("flintstone", object.get("fred"));
+        assertTrue(object.getBoolean("caveman"));
+
+        JSONArray friends = object.getJSONArray("friends");
+
+        assertEquals(1, friends.size());
+        assertEquals("barney", friends.get(0));
+    }
+
+    @Test
+    void appendToAnUnknownKeyCreatesAJSONArrayForTheValue() {
+
+        JSONObject object = new JSONObject();
+
+        object.append("friends", "barney");
+
+        assertEquals("{\"friends\":[\"barney\"]}", object.toCompactString());
+    }
+
+    @Test
+    void appendToAnExistingKeyContainingAJSONArrayAppendsToTheArray() {
+
+        JSONObject object = new JSONObject("{friends: [\"barney\"] }");
+
+        object.append("friends", "zaphod");
+
+        assertEquals("{\"friends\":[\"barney\",\"zaphod\"]}", object.toCompactString());
+    }
+
+    @Test
+    void appendingToAKeyWhoseValueIsNotAArrayIsAnException() {
+
+        JSONObject object = new JSONObject("{friends: 0 }");
+
+        JSONTypeMismatchException e = assertThrows(JSONTypeMismatchException.class, () -> {
+            object.append("friends", "zaphod");
+        });
+
+        assertTrue(e.getMessage().startsWith("JSONObject[\"friends\"] is not a ARRAY."));
+    }
+
+    @Test
+    void getDoubleWithANonNumericValueIsAnException() {
+
+        JSONObject object = new JSONObject("{notdouble: true}");
+
+        JSONTypeMismatchException e = assertThrows(JSONTypeMismatchException.class, () -> {
+            object.getDouble("notdouble");
+        });
+
+        assertTrue(e.getMessage().startsWith("JSONObject[\"notdouble\"] is not a NUMBER."));
+    }
+
+    @Test
+    void getDoubleWithAStringThatCanNotBeParsedAsANumberIsAnException() {
+
+        JSONObject object = new JSONObject("{notdouble: \"this is a fact\"}");
+
+        JSONTypeMismatchException e = assertThrows(JSONTypeMismatchException.class, () -> {
+            object.getDouble("notdouble");
+        });
+
+        assertTrue(e.getMessage().startsWith("JSONObject[\"notdouble\"] is not a NUMBER."));
+    }
+
+    @Test
+    void hasWillIdentifyWhichKeysHaveValuesAndWhichDoNot() {
+
+        JSONObject object = new JSONObject("{fred: 'flintstone'}");
+
+        assertTrue(object.containsKey("fred"));
+        assertFalse(object.containsKey("barney"));
+    }
+
+    @Test
+    void getJSONArrayForAValueThatIsNotAArrayIsAnException() {
+
+        JSONObject object = new JSONObject("{notarray: 22.7}");
+
+        JSONTypeMismatchException e = assertThrows(JSONTypeMismatchException.class, () -> {
+            object.getJSONArray("notarray");
+        });
+
+        assertTrue(e.getMessage().startsWith("JSONObject[\"notarray\"] is not a ARRAY."));
+    }
+
+    @Test
+    void lengthOfAJSONObjectIsTheNumberOfKeys() {
+
+        JSONObject object = new JSONObject();
+
+        assertEquals(0, object.size());
+
+        object.put("fred", "flintstone");
+
+        assertEquals(1, object.size());
+
+        object.accumulate("fred", "murray");
+
+        assertEquals(1, object.size());
+
+        object.put("barney", "rubble");
+
+        assertEquals(2, object.size());
+    }
+
+    @Test
+    void namesReturnsTheKeysAsAJSONArray() {
+
+        JSONObject object = new JSONObject("{fred: 'flintstone', barney: 'rubble' }");
+
+        JSONArray names = object.names();
+
+        assertNotNull(names);
+        assertTrue(names instanceof JSONArray);
+        assertEquals(2, names.size());
+
+        List<Object> nameList = new ArrayList<>(names.toList());
+        Collections.sort(nameList, (o1, o2) -> ((String) o1).compareTo((String) o2));
+
+        assertEquals(Arrays.asList("barney", "fred"), nameList);
+    }
+
+    @Test
+    void namesWithNoPropertiesReturnsNull() {
+
+        JSONObject object = new JSONObject();
+
+        assertNull(object.names());
+    }
+
+    @Test
+    void theNullLiteralIsTheNULLObject() {
+
+        JSONObject object = new JSONObject("{nullkey: null}");
+
+        assertSame(JSONObject.NULL, object.get("nullkey"));
+        assertTrue(object.isNull("nullkey"));
+    }
+
+    @Test
+    void theNULLObjectIsOutputAsNullLiteral() {
+
+        JSONObject object = new JSONObject().put("nullkey", JSONObject.NULL);
+
+        assertTrue(object.isNull("nullkey"));
+        assertEquals("{\"nullkey\":null}", object.toCompactString());
+    }
+
+    @Test
+    void theNULLObjectMatchesJavasNull() {
+
+        assertTrue(JSONObject.NULL.equals(null));
+    }
+
+    static Stream<Arguments> invalidInputProvider() {
+        return Stream.of(
+            Arguments.of("{  ", "A JSONObject text must end with '}' at character 3", "unmatched open brace"),
+            Arguments.of("fred", "A JSONObject text must start with '{' (actual: 'f') at character 1 of", "missing open brace"),
+            Arguments.of("{ \"akey\" }", "Expected a ':' after a key at character 10 of", "missing value after key"),
+            Arguments.of("{ \"fred\" : 1 \"barney\" }", "Expected a ',' or '}' at character 14 of", "missing property separator"),
+            Arguments.of("{ \"list\" : [1, 2", "Expected a ',' or ']' at character 16 of", "missing seperator or closing bracket"),
+            Arguments.of("/* unclosed", "Unclosed comment at character 11 of", "unclosed C-style comment"),
+            Arguments.of("{ \"fred \\n}", "Unterminated string at character 11 of", "unterminated string at line end"),
+            Arguments.of("{ fred: ,}", "Missing value at character 8 of ", "missing value after key")
+        );
+    }
+
+    @ParameterizedTest(name = "invalid input ({2}) throws exception")
+    @MethodSource("invalidInputProvider")
+    void invalidInputThrowsException(String input, String expected, String desc) {
+        // When/Then
+        JSONSyntaxException e = assertThrows(JSONSyntaxException.class, () -> {
+            new JSONObject(input);
+        });
+        
+        assertTrue(e.getMessage().trim().startsWith(expected), 
+            "Expected message to start with: " + expected + ", but was: " + e.getMessage().trim());
+    }
+
+
+    @Test
+    void canUseColonOrArrowAsKeySeperatorAndSemicolonAsPropertySeparator() {
+        JSONObject object = new JSONObject("{ \"fred\" = 1; \"barney\" => 2}");
+
+        assertEquals(1, object.getInt("fred"));
+        assertEquals(2, object.getInt("barney"));
+    }
+
+    static Stream<Arguments> numberToStringConversionProvider() {
+        return Stream.of(
+                Arguments.of(new BigDecimal("100.0000000"), "100")
+        );
+    }
+
+    @ParameterizedTest(name = "conversion of {0} ({argumentsWithNames}) to string is {1}")
+    @MethodSource("numberToStringConversionProvider")
+    void numberToStringConversion(Number value, String expected) {
+
+        assertEquals(expected, JSONObject.numberToString(value));
+    }
+
+   static Stream<Arguments> quoteProvider() {
+        return Stream.of(
+            Arguments.of(null, "\"\"", "null is empty string"),
+            Arguments.of("", "\"\"", "empty string is empty string"),
+            Arguments.of("\"/\b\t\n\f\r\u2001/a</", "\"\\\"/\\b\\t\\n\\f\\r\\u2001/a<\\/\"", "special characters"),
+            Arguments.of(
+                " \u0080\u009f\u00a0\u1fff\u2000\u20ff\u2100",
+                "\"\\u001f \\u0080\\u009f\u00a0\u1fff\\u2000\\u20ff\u2100\"",
+                "escape range boundaries: 0x1F/0x20, 0x7F/0x80/0x9F/0xA0, 0x1FFF/0x2000/0x20FF/0x2100"
+            )
+        );
+    }
+
+    @ParameterizedTest(name = "quote(): {2}")
+    @MethodSource("quoteProvider")
+    void quoteTest(String value, String expected, String desc) {
+
+        assertEquals(expected, JSONObject.quote(value));
+    }
+
+    static Stream<Arguments> nonFiniteNumbersProvider() {
+        return Stream.of(
+                Arguments.of(Double.NaN),
+                Arguments.of(Double.NEGATIVE_INFINITY),
+                Arguments.of(Double.POSITIVE_INFINITY),
+                Arguments.of(Float.NaN),
+                Arguments.of(Float.NEGATIVE_INFINITY),
+                Arguments.of(Float.POSITIVE_INFINITY)
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("nonFiniteNumbersProvider")
+    void nonFiniteNumbersAreNotAllowed(Number value) {
+
+        JSONObject object = new JSONObject();
+
+        RuntimeException e = assertThrows(IllegalArgumentException.class, () -> {
+            object.put("nonfinite", value);
+        });
+
+        assertEquals("JSON does not allow non-finite numbers.", e.getMessage());
+    }
+
+    @Test
+    void parseAnEmptyObjectIsEmpty() {
+        JSONObject object = new JSONObject("{}");
+        assertEquals(0, object.size());
+    }
+
+    @Test
+    void putAJavaNullIsARemove() {
+
+        JSONObject object = new JSONObject("{fred:'flintstone', barney:'rubble'}");
+
+        object.put("fred", null);
+
+        assertEquals("{\"barney\":\"rubble\"}", object.toCompactString());
+    }
+
+    static Stream<Arguments> validTypesForPutProvider() {
+        return Stream.of(
+                Arguments.of((Object) null),
+                Arguments.of(true),
+                Arguments.of(3),
+                Arguments.of(3.5),
+                Arguments.of("*VALUE*"),
+                Arguments.of(new JSONLiteral("*LITERAL*")),
+                Arguments.of(new JSONObject()),
+                Arguments.of(new JSONArray())
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("validTypesForPutProvider")
+    void onlySpecificObjectTypesMayBeAddedNoException(Object value) {
+
+        JSONObject object = new JSONObject("{}");
+
+        assertDoesNotThrow(() -> object.put("key", value));
+    }
+
+
+    static Stream<Arguments> invalidTypesForPutProvider() {
+        return Stream.of(
+                Arguments.of(new java.util.Date()),
+                Arguments.of((Object) new Object[] {}),
+                Arguments.of(Collections.emptyMap())
+        );
+    }
+    @ParameterizedTest
+    @MethodSource("invalidTypesForPutProvider")
+    void onlySpecificObjectTypesMayBeAddedException(Object value) {
+
+        JSONObject object = new JSONObject("{}");
+
+        assertThrows(JSONInvalidTypeException.class, () -> object.put("key", value));
+    }
+
+    @Test
+    void JSONStringCanOutputAnythingItWants() {
+        JSONString string = () -> "*VALUE*";
+        JSONObject object = new JSONObject();
+
+        object.put("key", string);
+
+        assertEquals("{\"key\":*VALUE*}", object.toCompactString());
+    }
+
+    @Test
+    void equalsImplementation() {
+        String jsonStr = "{ \"key\" : 99 }";
+        JSONObject obj1 = new JSONObject(jsonStr);
+        JSONObject obj2 = new JSONObject(jsonStr);
+
+        assertEquals(obj1, obj2);
+        assertEquals(obj2, obj1);
+        assertNotEquals(null, obj1);
+        assertNotEquals(jsonStr, obj1);
+
+        obj2.put("screw", "the pooch");
+
+        assertNotEquals(obj1, obj2);
+    }
+
+    @Test
+    void hashCodeImplementation() {
+
+        String jsonStr = "{ \"key\" : 99 }";
+        JSONObject obj1 = new JSONObject(jsonStr);
+        JSONObject obj2 = new JSONObject(jsonStr);
+
+        assertEquals(obj1.hashCode(), obj2.hashCode());
+
+        obj2.put("screw", "the pooch");
+
+        assertNotEquals(obj1.hashCode(), obj2.hashCode());
+    }
+
+    @Test
+    void escapedCharactersInTheJSONAreParsedCorrectly() {
+
+        JSONObject object = new JSONObject("{ \"key\" : \"\\\"/\\b\\t\\n\\f\\r\\u2001/a<\\/\\x20\" }");
+
+        assertEquals("\"/\b\t\n\f\r\u2001/a</ ", object.get("key"));
+    }
+
+    @Test
+    void parseANestedObject() {
+
+        JSONObject object = new JSONObject("{ key: { name: \"inner\" }}");
+
+        assertEquals("inner", object.getJSONObject("key").getString("name"));
+    }
+
+    @Test
+    void parseBooleanLiterals() {
+
+        JSONObject object = new JSONObject("{ t: true, f: false }");
+
+        assertTrue(object.getBoolean("t"));
+        assertFalse(object.getBoolean("f"));
+    }
+
+    @Test
+    void parseNumericForms() {
+
+        JSONObject object = new JSONObject("{ hex: 0x50, oct: 030, posInt: +50, negInt: -50, long: 4294968530, float: -32.7 }");
+
+        assertEquals(80, object.getInt("hex"));
+        assertEquals(24, object.getInt("oct"));
+        assertEquals(50, object.getInt("posInt"));
+        assertEquals(-50, object.getInt("negInt"));
+        assertEquals(4294968530L, object.getLong("long"));
+        assertEquals(-32.7d, object.getDouble("float"));
+    }
+
+    @Test
+    void NULLToStringIsNullKeyword() {
+
+        assertEquals("null", JSONObject.NULL.toString());
+    }
+
+    @Test
+    void JSONLiteralCanOutputAnyContent() {
+
+        String text = "function(x) { $('bar').show(); }";
+        JSONLiteral literal = new JSONLiteral(text);
+        JSONObject object = new JSONObject().put("callback", literal);
+
+        assertEquals("{\"callback\":" + text + "}", object.toCompactString());
+        assertEquals(text, literal.toString());
+    }
+
+    @Test
+    void prettyPrintTest() {
+
+        JSONObject object = new JSONObject("fred", "flintstone", "barney", "rubble");
+        String expected = "{\n  \"fred\" : \"flintstone\",\n  \"barney\" : \"rubble\"\n}";
+
+        assertEquals(expected.trim(), object.toString().trim());
+    }
+
+    @Test
+    void cStyleCommentsAreIgnored() {
+
+        JSONObject object = new JSONObject("/* C comment */ { \"foo\" : \"bar\" /* *ignored* */ }");
+
+        assertEquals("bar", object.get("foo"));
+    }
+
+    @Test
+    void doubleSlashCommentsExtendToEndOfLine() {
+
+        JSONObject object = new JSONObject("{ foo: // This is a double-slash comment\n\"bar\" }");
+
+        assertEquals("bar", object.get("foo"));
+    }
+
+    @Test
+    void hashCommentsExtendToEndOfLine() {
+
+        JSONObject object = new JSONObject("{ foo: # This is a hash comment\n\"bar\" }");
+
+        assertEquals("bar", object.get("foo"));
+    }
+
+    @Test
+    void prettyPrintAnEmptyJSONObject() {
+
+        JSONObject object = new JSONObject();
+
+        assertEquals("{}", object.toString());
+    }
+
+    @Test
+    void complexPrettyPrint() {
+        JSONObject object = new JSONObject(
+                "{ outer: { inner: \"here\" },\n" +
+                "  number: 22.7,\n" +
+                "  array: [1, 2, 3],\n" +
+                "  boolean: true,\n" +
+                "  otherwise: 'a quoted string'\n" +
+                "}"
+        );
+        String expected = "{\n" +
+                          "  \"outer\" : {\n" +
+                          "    \"inner\" : \"here\"\n" +
+                          "  },\n" +
+                          "  \"number\" : 22.7,\n" +
+                          "  \"array\" : [\n" +
+                          "    1,\n" +
+                          "    2,\n" +
+                          "    3\n" +
+                          "  ],\n" +
+                          "  \"boolean\" : true,\n" +
+                          "  \"otherwise\" : \"a quoted string\"\n" +
+                          "}";
+        assertEquals(expected.trim(), object.toString().trim());
+    }
+
+
+    @Test
+    void toCompactStringTest() {
+
+        JSONObject object = new JSONObject("kermit", "frog");
+
+        assertEquals("{\"kermit\":\"frog\"}", object.toString(true));
+        assertEquals("{\n  \"kermit\" : \"frog\"\n}", object.toString(false).replace("\r\n", "\n"));
+    }
+
+    private static String withPrintWriter(Consumer<PrintWriter> closure) {
+
+        CharArrayWriter caw = new CharArrayWriter();
+        try (PrintWriter pw = new PrintWriter(caw)) {
+            closure.accept(pw);
+        }
+
+        return caw.toString();
+    }
+
+    private static String print(JSONObject object, boolean compact) {
+        return withPrintWriter(pw -> object.print(pw, compact));
+    }
+
+    @Test
+    void printToPrintWriterTest() {
+
+        JSONObject object = new JSONObject("kermit", "frog");
+
+        assertEquals("{\"kermit\":\"frog\"}", print(object, true));
+        assertEquals("{\n  \"kermit\" : \"frog\"\n}", print(object, false).replace("\r\n", "\n"));
+    }
+
+
+    @Test
+    void prettyPrintToPrintWriter() {
+
+        JSONObject object = new JSONObject("kermit", "frog");
+
+        String json = withPrintWriter(pw -> object.prettyPrint(pw));
+        assertEquals("{\n  \"kermit\" : \"frog\"\n}", json.replace("\r\n", "\n"));
+    }
+
+    @Test
+    void getStringAtIndex() {
+
+        JSONArray array = new JSONArray("one", 2, false);
+
+        assertEquals("one", array.getString(0));
+        assertEquals("2", array.getString(1));
+        assertEquals("false", array.getString(2));
+    }
+
+    @Test
+    void toMapIsNotModifiable() {
+
+        JSONObject object = new JSONObject();
+        Map<String, Object> map = object.toMap();
+
+        assertThrows(UnsupportedOperationException.class, map::clear);
+    }
+
+    @Test
+    void canAccessContentsOfObjectAsAMap() {
+
+        JSONObject object = new JSONObject("foo", "bar")
+                .put("null", JSONObject.NULL)
+                .put("number", 6);
+        Map<String, Object> map = object.toMap();
+
+        assertEquals("bar", map.get("foo"));
+        assertEquals(6, map.get("number"));
+        assertSame(JSONObject.NULL, map.get("null"));
+    }
+
+    @Test
+    void theMapReturnedByToMapIsLive() {
+
+        JSONObject object = new JSONObject("foo", "bar");
+        Map<String, Object> map = object.toMap();
+
+        object.put("foo", null);
+
+        assertEquals(0, object.size());
+        assertTrue(map.isEmpty());
+    }
+
+    @Test
+    void canAddNewPropertiesViaPutAll() {
+
+        JSONObject object = new JSONObject("fred", "flintstone");
+        Map<String, Object> newProps = new HashMap<>();
+        newProps.put("barney", "rubble");
+        newProps.put("wilma", "flintstone");
+
+        object.putAll(newProps);
+
+        assertTrue(object.keySet().containsAll(Arrays.asList("fred", "barney", "wilma")));
+        assertEquals(
+            Stream.of("flintstone", "rubble", "flintstone").sorted().collect(Collectors.toList()),
+            object.values().stream().sorted().collect(Collectors.toList())
+        );
+    }
+
+    @Test
+    void inWhereTheValueIsNotJSONObjectIsAnException() {
+
+        JSONObject object = new JSONObject("time", "to rock");
+
+        IllegalStateException e = assertThrows(IllegalStateException.class, () -> {
+            object.in("time");
+        });
+
+        assertEquals("JSONObject[\"time\"] is not a JSONObject.", e.getMessage());
+    }
+
+    @Test
+    void inCreatesAJSONObjectIfNecessary() {
+
+        JSONObject object = new JSONObject();
+
+        object.in("nested").put("created", true);
+
+        assertEquals("{\"nested\":{\"created\":true}}", object.toCompactString());
+    }
+
+    @Test
+    void inReturnsTheJSONObject() {
+
+        JSONObject object = new JSONObject("{nested: {}}");
+
+        object.in("nested").put("time", "to rock");
+
+        assertEquals("{\"nested\":{\"time\":\"to rock\"}}", object.toCompactString());
+    }
+
+    @Test
+    void constructorSupportsNonStringKeysAndValues() {
+
+        JSONObject object = new JSONObject(true, "truthy", false, false);
+
+        assertTrue(object.containsKey("true")); // Keys are stringified
+        assertEquals("truthy", object.getString("true"));
+        assertTrue(object.containsKey("false"));
+        assertEquals(false, object.get("false"));
+    }
+
+
+    private static Object copyViaSerialization(Object source) throws IOException, ClassNotFoundException {
+
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
+            oos.writeObject(source);
+        }
+
+        ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
+        try (ObjectInputStream ois = new ObjectInputStream(bis)) {
+            return ois.readObject();
+        }
+    }
+
+    @Test
+    void serializeAndDeSerialize() throws IOException, ClassNotFoundException {
+
+        JSONObject source = new JSONObject("string", "a string", "null", JSONObject.NULL);
+
+        JSONObject copy = (JSONObject) copyViaSerialization(source);
+
+        assertEquals(source, copy);
+    }
+
+    // TAP5-2759
+    @Test
+    void getLongOrDefaultCorrectTypeForDefaultValue() {
+        JSONObject object = new JSONObject();
+        long defaultValue = 2_147_483_648L; // This is Integer.MAX_VALUE + 1, so it's a long
+        long value = object.getLongOrDefault("key", defaultValue);
+
+        assertEquals(defaultValue, value);
+    }
+}