RandomConformanceTest.java

package org.json.simple;

import java.math.BigDecimal;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;

import junit.framework.TestCase;

/**
 * Randomised differential test against JSON-java (org.json).
 *
 * ConformanceTest uses a hand-written corpus, which is deterministic and easy to
 * review but only covers what somebody thought of. This one generates documents
 * instead: text in a dozen scripts, integers and doubles across their whole
 * ranges, booleans, nulls, and arbitrarily combined arrays and objects. It is
 * looking for the combinations nobody thought of.
 *
 * Documents are generated two ways, because one generator cannot do both jobs
 * well. The first combines value types freely and produces broad shapes; with a
 * two-in-seven chance of a container at each slot it is flat about half the
 * time, which is fine for exercising leaves but poor at depth. The second makes
 * depth the variable: a chain of up to sixty-four containers, each
 * independently an object or an array, so object-in-array-in-object alternation
 * is the normal case, with a random number of ordinary members at every level.
 *
 * Runs are reproducible. The seed is fixed so that a green build means the same
 * thing every time; pass -Djson.simple.test.seed=N to explore a different part
 * of the space, and -Djson.simple.test.documents=N or
 * -Djson.simple.test.deep.documents=N to generate more. Every failure message
 * carries the seed, and the deep one the depth, needed to replay it.
 *
 * Three properties are checked for every generated document, covering both
 * directions:
 *
 *   - what json-simple writes, the reference reads back as the same data;
 *   - what the reference writes, json-simple reads back as the same data;
 *   - json-simple's own round trip reproduces its text exactly.
 *
 * Generation deliberately stays inside the intersection of the two libraries.
 * It produces no integer wider than a long (json-simple cannot read those - see
 * ConformanceTest), no duplicate keys and no trailing commas (the reference
 * rejects both), and no unpaired surrogates (not well-formed Unicode).
 */
public class RandomConformanceTest extends TestCase {

	private static final long DEFAULT_SEED = 20120129L;
	private static final int DEFAULT_DOCUMENTS = 2000;
	private static final int DEFAULT_DEEP_DOCUMENTS = 300;

	/** Shape generation: deep enough to combine containers freely. */
	private static final int MAX_DEPTH = 6;
	private static final int MAX_MEMBERS = 6;

	/**
	 * Depth generation. Both libraries recurse somewhere: json-simple when it
	 * encodes (measured at roughly 12,000 levels on a 1 MB stack, under 2,000 on
	 * 512 KB) and the reference when it parses (roughly 4,000 and 750). Sixty-four
	 * exercises real nesting while staying an order of magnitude clear of the
	 * smaller of those, so the test cannot turn into a stack-size measurement.
	 */
	private static final int MAX_CHAIN_DEPTH = 64;

	/**
	 * Code point ranges to draw text from. Latin, accented Latin, Greek,
	 * Cyrillic, Hebrew, Arabic, Devanagari, Thai, Hiragana, Katakana, CJK,
	 * Hangul, currency and punctuation blocks that json-simple escapes, C0
	 * controls, and emoji above the BMP.
	 */
	private static final int[][] SCRIPTS = {
		{ 0x0020, 0x007e }, { 0x00a0, 0x00ff }, { 0x0370, 0x03ff },
		{ 0x0400, 0x04ff }, { 0x0590, 0x05ff }, { 0x0600, 0x06ff },
		{ 0x0900, 0x097f }, { 0x0e00, 0x0e7f }, { 0x3040, 0x309f },
		{ 0x30a0, 0x30ff }, { 0x4e00, 0x9fff }, { 0xac00, 0xd7a3 },
		{ 0x2000, 0x20ff }, { 0x0000, 0x001f }, { 0x007f, 0x009f },
		{ 0x1f300, 0x1f6ff },
	};

	private long seed;
	private Random random;

	protected void setUp() {
		seed = Long.getLong("json.simple.test.seed", DEFAULT_SEED).longValue();
		random = new Random(seed);
	}

	/** Broad shapes: every value type, containers combined freely. */
	public void testGeneratedDocumentsSurviveBothLibraries() throws Exception {
		int documents = Integer.getInteger("json.simple.test.documents",
				DEFAULT_DOCUMENTS).intValue();

		for (int i = 0; i < documents; i++) {
			Object generated = random.nextBoolean() ? object(MAX_DEPTH) : array(MAX_DEPTH);
			verify(generated, "seed " + seed + ", document " + i);
		}
	}

	/**
	 * Depth specifically. The generator above combines containers, but with a
	 * two-in-seven chance of choosing one at each slot it produces a flat
	 * document about half the time and rarely reaches its own ceiling. This test
	 * makes depth the variable instead: a chain of up to sixty-four containers,
	 * each independently an object or an array, so object-in-array-in-object
	 * alternation is the normal case rather than a lucky one. Every level also
	 * carries a random number of ordinary members beside the one that continues
	 * the chain, so width varies with depth.
	 */
	public void testDeeplyNestedDocumentsSurviveBothLibraries() throws Exception {
		int documents = Integer.getInteger("json.simple.test.deep.documents",
				DEFAULT_DEEP_DOCUMENTS).intValue();

		for (int i = 0; i < documents; i++) {
			int depth = 1 + random.nextInt(MAX_CHAIN_DEPTH);
			verify(chain(depth), "seed " + seed + ", depth " + depth + ", document " + i);
		}
	}

	/**
	 * Both directions, for one document: what json-simple writes the reference
	 * must read as the same data, what the reference writes json-simple must
	 * read as the same data, and json-simple's own round trip must reproduce its
	 * text exactly rather than merely something equivalent.
	 */
	private void verify(Object generated, String label) throws Exception {
		String written = JSONValue.toJSONString(generated);
		String where = label + ": " + abbreviate(written);

		Object read;
		try {
			read = new org.json.JSONTokener(written).nextValue();
		} catch (Throwable failure) {
			throw new AssertionError("the reference could not read json-simple's output ("
					+ where + "): " + failure);
		}
		assertEquivalent(where, generated, read);

		String rewritten = String.valueOf(read);
		assertEquivalent("re-read of the reference's output (" + where + ")",
				JSONValue.parseWithException(rewritten), read);

		assertEquals("json-simple's round trip is not stable (" + where + ")",
				written, JSONValue.toJSONString(JSONValue.parseWithException(written)));
	}

	// ---------------------------------------------------------------- values

	private Object value(int depth) {
		int choice = random.nextInt(depth > 0 ? 7 : 5);
		switch (choice) {
			case 0: return null;
			case 1: return Boolean.valueOf(random.nextBoolean());
			case 2: return Long.valueOf(anyLong());
			case 3: return Double.valueOf(anyDouble());
			case 4: return text();
			case 5: return array(depth - 1);
			default: return object(depth - 1);
		}
	}

	private JSONArray array(int depth) {
		JSONArray array = new JSONArray();
		int size = random.nextInt(MAX_MEMBERS + 1);
		for (int i = 0; i < size; i++) {
			array.add(value(depth));
		}
		return array;
	}

	private JSONObject object(int depth) {
		JSONObject object = new JSONObject();
		int size = random.nextInt(MAX_MEMBERS + 1);
		for (int i = 0; i < size; i++) {
			// A key collision would silently shrink the object and, worse, the
			// reference rejects duplicates outright, so keep them distinct.
			object.put(text() + "#" + i, value(depth));
		}
		return object;
	}

	/**
	 * A chain of `depth` containers, each independently an object or an array,
	 * each carrying a random number of leaves alongside the single member that
	 * continues the chain. The continuing member sits at a random position, so
	 * the nesting is not always the first or last element.
	 */
	private Object chain(int depth) {
		Object node = value(0);
		for (int level = 0; level < depth; level++) {
			int siblings = random.nextInt(MAX_MEMBERS);
			if (random.nextBoolean()) {
				JSONObject object = new JSONObject();
				for (int i = 0; i < siblings; i++) {
					object.put(text() + "#" + i, value(0));
				}
				object.put("nested#" + level, node);
				node = object;
			} else {
				JSONArray array = new JSONArray();
				for (int i = 0; i < siblings; i++) {
					array.add(value(0));
				}
				array.add(random.nextInt(array.size() + 1), node);
				node = array;
			}
		}
		return node;
	}

	private long anyLong() {
		switch (random.nextInt(6)) {
			case 0: return 0L;
			case 1: return Long.MAX_VALUE;
			case 2: return Long.MIN_VALUE;
			case 3: return random.nextInt(1000);
			case 4: return -random.nextInt(1000);
			default: return random.nextLong();
		}
	}

	private double anyDouble() {
		switch (random.nextInt(7)) {
			case 0: return 0.0d;
			case 1: return -0.0d;
			case 2: return Double.MAX_VALUE;
			case 3: return Double.MIN_VALUE;
			case 4: return random.nextDouble();
			case 5: return -random.nextDouble() * 1e9;
			default:
				// Anything non-finite is encoded as null and cannot round trip.
				double value = Double.longBitsToDouble(random.nextLong());
				return Double.isNaN(value) || Double.isInfinite(value) ? 1.5d : value;
		}
	}

	private String text() {
		StringBuffer text = new StringBuffer();
		int length = random.nextInt(12);
		for (int i = 0; i < length; i++) {
			int[] script = SCRIPTS[random.nextInt(SCRIPTS.length)];
			int codePoint = script[0] + random.nextInt(script[1] - script[0] + 1);
			// Unpaired surrogates are not well-formed text; skip that block.
			if (Character.isSurrogate((char) codePoint)) {
				codePoint = 'x';
			}
			text.append(Character.toChars(codePoint));
		}
		return text.toString();
	}

	private static String abbreviate(String document) {
		return document.length() <= 200 ? document : document.substring(0, 200) + "...";
	}

	// ----------------------------------------------------------- comparison

	/** Same contract as ConformanceTest.assertEquivalent: compares meaning. */
	private static void assertEquivalent(String context, Object mine, Object theirs) {
		if (mine == null || org.json.JSONObject.NULL.equals(theirs)) {
			assertTrue(context + ": one side is null, the other is " + mine + " / " + theirs,
					mine == null && org.json.JSONObject.NULL.equals(theirs));
			return;
		}

		if (mine instanceof Map) {
			assertTrue(context + ": expected an object, got " + theirs.getClass().getName(),
					theirs instanceof org.json.JSONObject);
			Map mineMap = (Map) mine;
			org.json.JSONObject theirsObject = (org.json.JSONObject) theirs;
			assertEquals(context + ": different number of members",
					mineMap.size(), theirsObject.length());
			for (Iterator it = mineMap.keySet().iterator(); it.hasNext();) {
				String key = String.valueOf(it.next());
				assertTrue(context + ": the reference has no member " + key,
						theirsObject.has(key));
				assertEquivalent(context + "." + key, mineMap.get(key), theirsObject.get(key));
			}
			return;
		}

		if (mine instanceof List) {
			assertTrue(context + ": expected an array, got " + theirs.getClass().getName(),
					theirs instanceof org.json.JSONArray);
			List mineList = (List) mine;
			org.json.JSONArray theirsArray = (org.json.JSONArray) theirs;
			assertEquals(context + ": different number of elements",
					mineList.size(), theirsArray.length());
			for (int i = 0; i < mineList.size(); i++) {
				assertEquivalent(context + "[" + i + "]", mineList.get(i), theirsArray.get(i));
			}
			return;
		}

		if (mine instanceof Number) {
			assertTrue(context + ": expected a number, got " + theirs.getClass().getName(),
					theirs instanceof Number);
			assertEquals(context + ": " + mine + " != " + theirs, 0,
					new BigDecimal(mine.toString()).compareTo(new BigDecimal(theirs.toString())));
			return;
		}

		assertEquals(context, mine, theirs);
	}
}