diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d655879 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target/ +# Eclipse: +/.classpath +/.project +/.settings/ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ce0108c --- /dev/null +++ b/pom.xml @@ -0,0 +1,52 @@ + + 4.0.0 + + com.xocevad.engineering + ontraport-skills-test + 0.0.1-SNAPSHOT + Job application skills test for ONTRAPORT + + + 11 + true + + + + + + com.google.code.gson + gson + 2.8.6 + + + + + org.junit.jupiter + junit-jupiter + 5.5.2 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 11 + 11 + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + + + \ No newline at end of file diff --git a/src/main/java/com/xocevad/engineering/skills/Skills.java b/src/main/java/com/xocevad/engineering/skills/Skills.java new file mode 100644 index 0000000..6c20367 --- /dev/null +++ b/src/main/java/com/xocevad/engineering/skills/Skills.java @@ -0,0 +1,239 @@ +package com.xocevad.engineering.skills; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Vector; + +/** + * Implements functions specified by ONTRAPORT's online GitHub Backend-Test, + * here named convertMultiToOne() and convertOneToMulti(). + */ +public class Skills { + + /** + * Converts a multi-dimensional hierarchal container to a one-dimensional + * associative array, whose string keys represent paths in the hierarchy, and + * containing the same ultimate values. Note array indexes, as well as map keys, + * are converted to string path elements. + * + * @param multiDim multi-dimensional container. It is an error to pass an Object + * that is neither a Map nor an array. + * + * @return one-dimensional associative array (Map). + */ + public static Map convertMultiToOne(Object multiDim) { + var result = new LinkedHashMap(); + if (multiDim instanceof Map) { + addMapToByPathMap((Map)multiDim, "", result); + + } else if (multiDim.getClass().isArray()) { + addArrayToByPathMap((Object[])multiDim, "", result); + + } else { + throw new IllegalArgumentException(String.format( + "unexpected type of argument multiDim: %s", multiDim.getClass())); + } + return result; + } + + private static void addObjectToByPathMap(Object obj, String path, Map out) { + // classify value: container or simple value + if (obj == null) { + addSingleToByPathMap(obj, path, out); + + } else if (obj instanceof Map) { + addMapToByPathMap((Map)obj, path, out); + + } else if (obj.getClass().isArray()) { + addArrayToByPathMap((Object[])obj, path, out); + + } else { + addSingleToByPathMap(obj, path, out); + } + } + + private static void addMapToByPathMap(Map map, String path, Map out) { + map.forEach((name, value) -> addNamedValueToByPathMap(name.toString(), value, path, out)); + } + + private static void addArrayToByPathMap(Object[] array, String path, Map out) { + var nElements = array.length; + for (int n = 0; n < nElements; ++n) { + String name = Integer.toString(n); + addNamedValueToByPathMap(name, array[n], path, out); + } + } + + private static void addSingleToByPathMap(Object obj, String path, Map out) { + // not a container; a leaf value; record the path and value. + out.put(path, obj); + } + + private static void addNamedValueToByPathMap( + String name, + Object value, + String path, + Map out) { + // append name to path + if (path.length() > 0) { + // not empty; needs path delimiter + path += kPathDelimiter + name; + } else { + path += name; + } + addObjectToByPathMap(value, path, out); + } + + //--------------------------------------------------------------------------------------------// + + /** + * Converts a one-dimensional associative array, whose keys are strings + * representing paths in a hierarchy, to a multi-dimensional hierarchal + * container, containing the same ultimate values. Path elements are converted + * to indexes into containers: either string keys into a Map, or integer indexes + * into an array when appropriate. + * + * @param byPath one-dimensional associative array (Map) whose keys represent + * paths. + * + * @return multi-dimensional hierarchal container. + */ + public static Object convertOneToMulti(Map byPath) { + var root = new ContainerNode(); + byPath.forEach((path, value) -> { + // they say split() doesn't treat arg as a (slow) regex if it's one char. + var pathElements = path.split(kPathDelimiter, -1); + var pathElemsList = Arrays.asList(pathElements); // backed by the array + + addValueToNodeAtPath(value, root, pathElemsList); + }); + // Render Nodes to maps, arrays and objects. + return root.render(); + } + + private static void addValueToNodeAtPath( + Object value, ContainerNode node, ListpathElemsList) { + + //System.out.format("addValueToNodeAtPath(), path: %s", pathElemsList).println(); + + var nPathElems = pathElemsList.size(); + var name = pathElemsList.get(0); + if (nPathElems < 1) { + throw new IllegalArgumentException("malformed path in map"); + } else if (nPathElems == 1) { + // We've consumed all the path; time to insert the leaf value. + //System.out.format(" inserting value: %s", value).println(); + node.put(name, new ObjectNode(value)); + } else { + // We have multiple path elements; the object corresponding to the + // front element must be a container (or the paths were inconsistent + // in which case throw). + ContainerNode subNode; + try { + subNode = (ContainerNode)node.get(name); + } catch (ClassCastException x) { + throw new IllegalArgumentException("inconsistent paths in map", x); + } + if (subNode == null) { + // First time encountering this name within this node, so create the subNode. + subNode = new ContainerNode(); + node.put(name, subNode); + } + // subList will be backed by same array + addValueToNodeAtPath(value, subNode, pathElemsList.subList(1, nPathElems)); + } + } + + // Node in a tree, representing either a single value, or a container. We don't know, while + // parsing path-value pairs, if a container should be an array, so all containers are Maps. + // Once all containers are populated and all keys are observed, we can determine that a + // particular container should have been an array. The render() abstraction allows such + // ContainerNode to substitute an array for the map. Thus recursive render() is necessary + // to replace a tree of Nodes with a tree of Map, Object[] and Object values. + private interface Node { + Object render(); + } + + // Node for a single-object value + private static class ObjectNode implements Node { + + ObjectNode(Object obj) { + this.object = obj; + } + + @Override + public Object render() { + return object; + } + + private Object object; + } + + // Node for a container (map or array) + private static class ContainerNode implements Node { + + public void put(String name, Object value) { + map.put(name, value); + if (integerIndexes && !nameLooksLikeIntegerIndex(name)) { + integerIndexes = false; + } + } + + public Object get(String name) { + return map.get(name); + } + + @Override + public Object render() { + // Replace all the contained Nodes with their wrapped Objects: + // render all the entries recursively. + for (var key : map.keySet()) { + var value = (Node)map.get(key); + map.put(key, value.render()); + } + if (integerIndexes) { + // all keys look like integers; convert the Map to an array. + return intStrIndexedMapToArray(map); + } + return map; + } + + private boolean nameLooksLikeIntegerIndex(String name) { + try { + var n = Integer.parseInt(name); + // array indexes must be non-negative + return (n >= 0); + } catch (NumberFormatException x) { + return false; + } + } + + private Object[] intStrIndexedMapToArray(Map map) { + // The map might represent a sparse array, with index value(s) greater than the + // size of the map. Use a Vector that can grow to the size needed, rather than + // a fixed-length array matching the map size. + var vector = new Vector(map.size()); + vector.setSize(map.size()); + map.forEach((name, value) -> { + int n = Integer.parseInt(name); + if (n >= vector.size()) { + vector.setSize(n + 1); + } + vector.set(n, value); + }); + return vector.toArray(); + } + + private Map map = new LinkedHashMap<>(); + + private boolean integerIndexes = true; + + } + + //--------------------------------------------------------------------------------------------// + + private static final String kPathDelimiter = "/"; + +} diff --git a/src/test/java/com/xocevad/engineering/skills/SkillsTest.java b/src/test/java/com/xocevad/engineering/skills/SkillsTest.java new file mode 100644 index 0000000..168af8f --- /dev/null +++ b/src/test/java/com/xocevad/engineering/skills/SkillsTest.java @@ -0,0 +1,262 @@ +package com.xocevad.engineering.skills; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.google.gson.JsonParser; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonArray; +import com.google.gson.JsonPrimitive; + +import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + + +/** + * Unit tests of the class Skills implementing functions specified by ONTRAPORT's online + * GitHub Backend-Test, here named convertMultiToOne() and convertOneToMulti(). + * + * Note: test values (input and expected output) are committed as json text files. The tests + * convert these (by way of GSON) to Java containers (maps/arrays). The class under test, + * "Skills", deals in such native Java objects, with no awareness of JSON. + * + * Note the files ontraport-deep.json and ontraport-flat.json are the original examples given + * in the Backend-Test repo README.md (with double-quotes rather than single, making it proper + * JSON). I have added several other files, supporting more thorough testing. + */ +public class SkillsTest { + + //--------------------------------------------------------------------------------------------// + + // Test convertMultiToOne(): convert a multi-dimensional hierarchal container to a + // one-dimensional associative array, whose keys represent paths in the hierarchy. + @Test + public void testConvertMultiToOne() throws IOException { + var input = readHierJsonTextFileToJava("src/test/resources/ontraport-deep.json"); + var expectedOutput = readFlatJsonTextFileToJava("src/test/resources/ontraport-flat.json"); + + // invoke the method in question + var output = Skills.convertMultiToOne(input); + assertTreeEquals(expectedOutput, output); +} + + // Observe mismatch of containers when the json file text is similarly mismatched. + @Test + public void testConvertMultiToOneNegative() throws IOException { + var input = readHierJsonTextFileToJava("src/test/resources/ontraport-deep.json"); + // Load a file with slightly different structure than ontraport-flat.json, + // demonstrate that it does *not* compare equal to the product of + // convertMultiToOne(ontraport-deep.json). + var expectedOutput = readFlatJsonTextFileToJava("src/test/resources/negative-flat-1.json"); + + // invoke the method in question + var output = Skills.convertMultiToOne(input); + assertTreeNotEquals(expectedOutput, output); + + // another file slightly mismatched + expectedOutput = readFlatJsonTextFileToJava("src/test/resources/negative-flat-2.json"); + assertTreeNotEquals(expectedOutput, output); + + } + + // A more challenging test than testConvertMultiToOne(): + // - The top-most construct of the multi-dimensional container is an array + // rather than an object with named members. + // - The container holds values of heterogeneous types. + // - Some values are null. + // - An object has a member named like an integer, making it look like an array index. + @Test + public void testConvertMultiToOneChallenging() throws IOException { + var input = readHierJsonTextFileToJava("src/test/resources/challenge-deep.json"); + var expectedOutput = readFlatJsonTextFileToJava("src/test/resources/challenge-flat.json"); + + // invoke the method in question + var output = Skills.convertMultiToOne(input); + assertTreeEquals(expectedOutput, output); + } + + + //--------------------------------------------------------------------------------------------// + + // Test the reverse, convertOneToMulti(): convert one-dimensional associative array, whose + // keys represent paths in a hierarchy, to a multi-dimensional hierarchal container. + @Test + public void testConvertOneToMulti() throws IOException { + var input = readFlatJsonTextFileToJava("src/test/resources/ontraport-flat.json"); + var expectedOutput = readHierJsonTextFileToJava("src/test/resources/ontraport-deep.json"); + + // invoke the method in question + var output = Skills.convertOneToMulti(input); + assertTreeEquals(expectedOutput, output); + } + + // Observe mismatch of containers when the json file text is similarly mismatched. + @Test + public void testConvertOneToMultiNegative() throws IOException { + var input = readFlatJsonTextFileToJava("src/test/resources/ontraport-flat.json"); + var expectedOutput = readHierJsonTextFileToJava("src/test/resources/negative-deep-1.json"); + + // invoke the method in question + var output = Skills.convertOneToMulti(input); + assertTreeNotEquals(expectedOutput, output); + + // another file slightly mismatched + expectedOutput = readFlatJsonTextFileToJava("src/test/resources/negative-deep-2.json"); + assertTreeNotEquals(expectedOutput, output); + } + + // A more challenging test than testConvertOneToMulti(): + // - The top-most construct of the multi-dimensional container should be an array + // rather than an object with named members. + // - The container holds values of heterogeneous types. + // - Some values are null. + // - An object has a member named like an integer, making it look like an array index. + @Test + public void testConvertOneToMultiChallenging() throws IOException { + var input = readFlatJsonTextFileToJava("src/test/resources/challenge-flat.json"); + var expectedOutput = readHierJsonTextFileToJava("src/test/resources/challenge-deep.json"); + + // invoke the method in question + var output = Skills.convertOneToMulti(input); + assertTreeEquals(expectedOutput, output); + } + + //--------------------------------------------------------------------------------------------// + // supporting utilities + + JsonElement readJsonTextFile(String filePath) throws IOException { + String content = new String(Files.readAllBytes(Paths.get(filePath))); + return JsonParser.parseString(content); + } + + // Read hierarchical, i.e. multi-dimensional + Object readHierJsonTextFileToJava(String filePath) throws IOException { + var element = readJsonTextFile(filePath); + return jsonToJava(element); + } + + // Read flat, i.e. one-dimensional + Map readFlatJsonTextFileToJava(String filePath) throws IOException { + var element = readJsonTextFile(filePath); + return jsonToJava(element.getAsJsonObject()); + } + + private static Object jsonToJava(JsonElement json) { + if (json.isJsonObject()) { + return jsonToJava(json.getAsJsonObject()); + + } else if (json.isJsonArray()) { + return jsonToJava(json.getAsJsonArray()); + + } else if (json.isJsonPrimitive()) { + return jsonToJava(json.getAsJsonPrimitive()); + + } else if (json.isJsonNull()) { + return null; + + } else { + // should never happen + throw new RuntimeException("unexpected type in JsonElement"); + } + } + + private static Map jsonToJava(JsonObject json) { + // has members: key-value pairs, key=String, value=JsonElement + var map = new LinkedHashMap(); + for (var entry : json.entrySet()) { + map.put(entry.getKey(), jsonToJava(entry.getValue())); + } + return map; + } + + private static Object[] jsonToJava(JsonArray json) { + // unnamed members + var nElements = json.size(); + var array = new Object[nElements]; + for (int n = 0; n < nElements; ++n) { + array[n] = jsonToJava(json.get(n)); + } + return array; + } + + private static Object jsonToJava(JsonPrimitive json) { + if (json.isBoolean()) { + return Boolean.valueOf(json.getAsBoolean()); + } else if (json.isNumber()) { + return jsonToBoxedNumeric(json); + } else if (json.isString()) { + return json.getAsString(); + } else { + throw new RuntimeException("unexpected type in JsonPrimitive"); + } + } + + private static Object jsonToBoxedNumeric(JsonPrimitive json) throws NumberFormatException { + try { + long l = json.getAsLong(); + double dl = (double)l; + double d = json.getAsDouble(); + if (d != dl) { + // getAsLong() performed rounding, so prefer Double value. + return Double.valueOf(d); + } + // fractional part is 0 so prefer integral type to avoid serializing with ".0" + return Long.valueOf(l); + + } catch (NumberFormatException ignored) { + // getAsLong() threw: the value contained is not a valid long + } + + return Double.valueOf(json.getAsDouble()); + } + + //--------------------------------------------------------------------------------------------// + + void assertTreeEquals(Object expected, Object actual) { + if (expected == null) { + assertEquals(expected, actual); + } else if (expected instanceof Map) { + assertMapEquals((Map)expected, (Map)actual); + } else if (expected.getClass().isArray()) { + assertArrayEquals((Object[])expected, (Object[])actual); + } else { + assertEquals(expected, actual); + } + } + + void assertTreeNotEquals(Object unexpected, Object actual) { + try { + assertTreeEquals((Map)unexpected, (Map)actual); + // if it didn't fail, they are equal, contrary to this method's assertion. + fail(String.format("unexpected: %s but got equal: %s", unexpected, actual)); + } catch (AssertionFailedError e) { + // nothing to do: args are not equal, as asserted. + } + } + + void assertMapEquals(Map expected, Map actual) { + assertEquals(expected.size(), actual.size()); + var iterActual = actual.entrySet().iterator(); + for (var expectedEntry : expected.entrySet()) { + var actualEntry = iterActual.next(); + assertEquals(expectedEntry.getKey(), actualEntry.getKey()); + assertTreeEquals(expectedEntry.getValue(), actualEntry.getValue()); + } + } + + void assertArrayEquals(Object[] expected, Object[] actual) { + var length = expected.length; + assertEquals(length, actual.length); + for (int n = 0; n < length; ++n) { + assertTreeEquals(expected[n], actual[n]); + } + } + +} diff --git a/src/test/resources/challenge-deep.json b/src/test/resources/challenge-deep.json new file mode 100644 index 0000000..75cb581 --- /dev/null +++ b/src/test/resources/challenge-deep.json @@ -0,0 +1,27 @@ +[ + "fluffy", + { + "flags":[false, true, true, false, "undefined"], + "oranges":["navel", "valencia", "mandarin"], + "pi":3.14159265, + "answers":[ + true, + false, + false, + "c", + "b", + 1971, + null, + "d" + ], + "tricky":{ + "0":"my hero", + "red":"roses", + "blue":"violets" + } + }, + 42, + null, + "spiky", + [false, true, 1,2,3,5,8,13,20.9999] +] diff --git a/src/test/resources/challenge-flat.json b/src/test/resources/challenge-flat.json new file mode 100644 index 0000000..dae6adc --- /dev/null +++ b/src/test/resources/challenge-flat.json @@ -0,0 +1,42 @@ +{ + "0":"fluffy", + + "1/flags/0":false, + "1/flags/1":true, + "1/flags/2":true, + "1/flags/3":false, + "1/flags/4":"undefined", + + "1/oranges/0":"navel", + "1/oranges/1":"valencia", + "1/oranges/2":"mandarin", + + "1/pi":3.14159265, + + "1/answers/0":true, + "1/answers/1":false, + "1/answers/2":false, + "1/answers/3":"c", + "1/answers/4":"b", + "1/answers/5":1971, + "1/answers/6":null, + "1/answers/7":"d", + + "1/tricky/0":"my hero", + "1/tricky/red":"roses", + "1/tricky/blue":"violets", + + "2":42, + "3":null, + "4":"spiky", + + "5/0":false, + "5/1":true, + "5/2":1, + "5/3":2, + "5/4":3, + "5/5":5, + "5/6":8, + "5/7":13, + "5/8":20.9999 +} \ No newline at end of file diff --git a/src/test/resources/negative-deep-1.json b/src/test/resources/negative-deep-1.json new file mode 100644 index 0000000..7c19950 --- /dev/null +++ b/src/test/resources/negative-deep-1.json @@ -0,0 +1,15 @@ +{ + "one": + { + "two": 3, + "four": [ 5,6,7], + "doce":13 + }, + "eight": + { + "nine": + { + "ten":11 + } + } +} diff --git a/src/test/resources/negative-deep-2.json b/src/test/resources/negative-deep-2.json new file mode 100644 index 0000000..a97ebd3 --- /dev/null +++ b/src/test/resources/negative-deep-2.json @@ -0,0 +1,14 @@ +{ + "one": + { + "two": 3, + "four": [ 5,6,7] + }, + "eight": + { + "nine": + { + "ten":"once" + } + } +} diff --git a/src/test/resources/negative-flat-1.json b/src/test/resources/negative-flat-1.json new file mode 100644 index 0000000..c34f895 --- /dev/null +++ b/src/test/resources/negative-flat-1.json @@ -0,0 +1,7 @@ +{ + "one/two":3, + "one/four/0":5, + "one/four/1":6, + "one/four/9":7, + "eight/nine/ten":11 +} diff --git a/src/test/resources/negative-flat-2.json b/src/test/resources/negative-flat-2.json new file mode 100644 index 0000000..59f6dd4 --- /dev/null +++ b/src/test/resources/negative-flat-2.json @@ -0,0 +1,7 @@ +{ + "one/two":3, + "one/four/0":5, + "one/four/1":6, + "one/four/2":7, + "eight/nine/ten":10 +} diff --git a/src/test/resources/ontraport-deep.json b/src/test/resources/ontraport-deep.json new file mode 100644 index 0000000..c39a1b0 --- /dev/null +++ b/src/test/resources/ontraport-deep.json @@ -0,0 +1,14 @@ +{ + "one": + { + "two": 3, + "four": [ 5,6,7] + }, + "eight": + { + "nine": + { + "ten":11 + } + } +} diff --git a/src/test/resources/ontraport-flat.json b/src/test/resources/ontraport-flat.json new file mode 100644 index 0000000..7000d2a --- /dev/null +++ b/src/test/resources/ontraport-flat.json @@ -0,0 +1,7 @@ +{ + "one/two":3, + "one/four/0":5, + "one/four/1":6, + "one/four/2":7, + "eight/nine/ten":11 +}