diff --git a/connect/util/Diff.hx b/connect/util/Diff.hx new file mode 100644 index 00000000..229d7c2b --- /dev/null +++ b/connect/util/Diff.hx @@ -0,0 +1,290 @@ +/* + This file is part of the Ingram Micro CloudBlue Connect SDK. + Copyright (c) 2019 Ingram Micro. All Rights Reserved. +*/ +package connect.util; + +import haxe.ds.StringMap; + +/** + The `Diff` class stores the difference between two Haxe dynamic objects `first` and `second`. + You can later `apply` the diff object to `first` to obtain `second`, or `swap` the diff and then + `apply` to `second` to get `first`. +**/ +class Diff { + private final a:StringMap; // Additions + private final d:StringMap; // Deletions + private final c:StringMap; // Changes + + /** + Creates a new `Diff` storing the differences between the two objects passed. The differences basically are: + + - Additions: Fields present in `second` that are not present in `first`. + - Deletions: Fields present in `first` that are not present in `second`. + - Changes: Fields whose value has changed between `first` and `second`. + + The class is capable of tracking changes inside arrays. + **/ + public function new(first:Dynamic, second:Dynamic) { + checkStructs(first, second); + final firstFields = Reflect.fields(first); + final secondFields = Reflect.fields(second); + final addedFields = [for (f in secondFields) if (!Reflect.hasField(first, f)) f]; + final deletedFields = [for (f in firstFields) if (!Reflect.hasField(second, f)) f]; + final commonFields = [for (f in firstFields) if (Reflect.hasField(second, f)) f]; + final changedFields = commonFields.filter(function(f) { + return !areEqual(Reflect.field(first, f), Reflect.field(second, f)); + }); + + // Set additions + this.a = new StringMap(); + Lambda.iter(addedFields, (f) -> this.a.set(f, Reflect.field(second, f))); + + // Set deletions + this.d = new StringMap(); + Lambda.iter(deletedFields, (f) -> this.d.set(f, Reflect.field(first, f))); + + // Set changes + this.c = new StringMap(); + Lambda.iter(changedFields, function(f) { + final a: Dynamic = Reflect.field(first, f); + final b: Dynamic = Reflect.field(second, f); + if (isStruct(a) && isStruct(b)) { + // Diff + this.c.set(f, new Diff(a, b)); + } else if (isArray(a) && isArray(b)) { + // [[a], [d], [c]] + this.c.set(f, compareArrays(a, b)); + } else { + // [old, new] + this.c.set(f, [a, b]); + } + }); + } + + private static function isStruct(value:Dynamic):Bool { + return Type.typeof(value) == TObject; + } + + private static function isArray(value:Dynamic):Bool { + return Std.isOfType(value, Array); + } + + private static function checkStructs(first:Dynamic, second:Dynamic):Void { + if (!isStruct(first) || !isStruct(second)) { + throw 'Unsupported types in Diff. Values must be structs. ' + + 'Got: ${Type.typeof(first)}, ${Type.typeof(second)}'; + } + } + + private static function areEqual(first:Dynamic, second:Dynamic):Bool { + return Std.string(Type.typeof(first)) == Std.string(Type.typeof(second)) + && Std.string(first) == Std.string(second); + } + + private static function compareArrays(first:Array, second:Array):Array> { + final fixedFirst = (first.length <= second.length) + ? first + : first.slice(0, second.length); + final changeList = Lambda.mapi(fixedFirst, function(i, el): Array { + final a: Dynamic = el; + final b: Dynamic = second[i]; + if (areEqual(a, b)) { + return null; + } else { + if (isStruct(a) && isStruct(b)) { + return [i, new Diff(a, b)]; + } else if (isArray(a) && isArray(b)) { + return [i, compareArrays(a, b)]; + } else { + return [i, a, b]; + } + } + }); + final changes = [for (el in changeList) el].filter(el -> el != null); + return [ + second.slice(first.length), + first.slice(second.length), + changes + ]; + } + + /** + Applies to changes in `this` `Diff` to the dynamic object passed as argument. + If this method is called on the `first` object passed when constructing `this`, + then an object identical to `second` is returned. + **/ + public function apply(obj:Dynamic):Dynamic { + final out = Reflect.copy(obj); + + // Additions + final addedKeys = [for (k in this.a.keys()) k]; + Lambda.iter(addedKeys, k -> Reflect.setField(out, k, this.a.get(k))); + + // Deletions + final deletedKeys = [for (k in this.d.keys()) k]; + Lambda.iter(deletedKeys, k -> Reflect.deleteField(out, k)); + + // Changes + final changedKeys = [for (k in this.c.keys()) k]; + Lambda.iter(changedKeys, function(k) { + final change = this.c.get(k); + if (Std.isOfType(change, Array)) { + if (change.length == 2) { + // [old, new] + Reflect.setField(out, k, cast(change, Array)[1]); + } else { + // [[a], [d], [c]] + final field = Reflect.field(out, k); + final original = (field != null) ? field : []; + Reflect.setField(out, k, applyArray(original, change)); + } + } else { + // Diff + final field = Reflect.field(out, k); + final original = (field != null) ? field : {}; + Reflect.setField(out, k, change.apply(original)); + } + }); + + return out; + } + + private static function applyArray(obj:Array, arr:Array>):Array { + // Apply deletions + final slice = obj.slice(0, obj.length - arr[1].length); + final deleted = (slice != null) ? slice : []; + + // Apply additions + final added = deleted.concat(arr[0]); + + // Apply changes + final out = added; + Lambda.iter(arr[2], function(change: Array) { + final i = change[0]; + final originalArray: Dynamic = (out.length > i) ? out[i] : []; + final originalObject = (out.length > i) ? out[i] : {}; + out[i] = (change.length == 3) + ? change[2] // [i, old, new] + : (Std.isOfType(change[1], Array)) + ? applyArray(originalArray, change[1]) // [i, [[a], [d], [c]]] + : change[1].apply(originalObject); // [i, Diff] + }); + + return out; + } + + /** + Returns a new `Diff` that reverts the changes made by this one. If `apply` is called on + the returned `Diff` passing the `second` argument used to construct `this`, then and object + identical to the `first` argument used to construct `this` will be returned by `apply`. + **/ + public function swap():Diff { + final additions = this.d; + final deletions = this.a; + final changes = new StringMap(); + final changedKeys = [for (k in this.c.keys()) k]; + Lambda.iter(changedKeys, function(k) { + final change = this.c.get(k); + if (Std.isOfType(change, Array)) { + if (change.length == 2) { + // [old, new] + changes.set(k, [cast(change, Array)[1], cast(change, Array)[0]]); + } else { + // [[a], [d], [c]] + changes.set(k, swapArray(change)); + } + } else { + // Diff + changes.set(k, change.swap()); + } + }); + + final diff = Type.createEmptyInstance(Diff); + Reflect.setField(diff, 'a', additions); + Reflect.setField(diff, 'd', deletions); + Reflect.setField(diff, 'c', changes); + return diff; + } + + private static function swapArray(arr:Array>):Array> { + final additions = arr[1]; + final deletions = arr[0]; + final changes = arr[2]; + final swappedChanges: Array> = changes.map(function(change: Array) { + final i: Dynamic = change[0]; + final first: Dynamic = change[1]; + final second: Dynamic = change[2]; + final swappedChange: Array = + (change.length == 3) ? + [i, second, first] + : (Std.isOfType(first, Array)) ? + [i, swapArray(first)] + : + [i, first.swap()]; + return swappedChange; + }); + + return [ + additions, + deletions, + swappedChanges + ]; + } + + /** + Returns a string with the Json representation of `this` `Diff`. + **/ + public function toString():String { + return haxe.Json.stringify(this.toObject()); + } + + private function toObject():Dynamic { + final obj = { + a: mapToObject(this.a), + d: mapToObject(this.d), + c: {} + }; + final changedKeys = [for (k in this.c.keys()) k]; + Lambda.iter(changedKeys, function(key) { + final value = this.c.get(key); + if (Std.isOfType(value, Diff)) { + // Diff + Reflect.setField(obj.c, key, value.toObject()); + } else if (isArray(value) && value.length == 3) { + // [[a], [d], [c]] + Reflect.setField(obj.c, key, changeArrayToObject(value)); + } else { + // [old, new] + Reflect.setField(obj.c, key, value); + } + }); + return obj; + } + + private static function mapToObject(map:StringMap):Dynamic { + final keys = [for (k in map.keys()) k]; + final obj = {}; + Lambda.iter(keys, key -> Reflect.setField(obj, key, map.get(key))); + return obj; + } + + private static function changeArrayToObject(arr:Array>):Array> { + final changesList = Lambda.map(arr[2], function(el:Array):Array { + if (Std.isOfType(el[1], Diff)) { + return [el[0], el[1].toObject()]; + } else if (isArray(el[1])) { + return [el[0], changeArrayToObject(untyped el[1])]; + } else { + return el; + } + }); + final changes = [for (change in changesList) change]; + final arr = [ + arr[0], + arr[1], + changes + ]; + return arr; + } +} diff --git a/connect/util/Util.hx b/connect/util/Util.hx index 3e298b73..f62095fd 100644 --- a/connect/util/Util.hx +++ b/connect/util/Util.hx @@ -197,7 +197,7 @@ class Util { */ public static function createObjectDiff(object:Dynamic, previous:Dynamic):Dynamic { return Util.addIdsToObject( - new diff.Diff(previous, object).apply({id: object.id}), + new Diff(previous, object).apply({id: object.id}), previous); } diff --git a/package.hxml b/package.hxml index 036d8789..a2e6ca5f 100644 --- a/package.hxml +++ b/package.hxml @@ -1,6 +1,5 @@ -main Connect -dce no --lib diff:1.0.0 --each -D cslib diff --git a/test/unit/DiffTest.hx b/test/unit/DiffTest.hx new file mode 100644 index 00000000..a0385832 --- /dev/null +++ b/test/unit/DiffTest.hx @@ -0,0 +1,773 @@ +/* + This file is part of the Ingram Micro CloudBlue Connect SDK. + Copyright (c) 2019 Ingram Micro. All Rights Reserved. +*/ +import connect.util.Diff; +import haxe.Json; +import massive.munit.Assert; + + +class DiffTest { + @Test + public function testAdditions() { + final a = {x: 'Hello'}; + final b = {x: 'Hello', y: 'World'}; + final diff = new Diff(a, b); + final expected = { + a: {y: 'World'}, + d: {}, + c: {} + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testDeletions() { + final a = {x: 'Hello', y: 'World'}; + final b = {y: 'World'}; + final diff = new Diff(a, b); + final expected = { + a: {}, + d: {x: 'Hello'}, + c: {} + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesNoChange() { + final a = {x: 'Hello'}; + final b = {x: 'Hello'}; + final diff = new Diff(a, b); + final expected = { + a: {}, + d: {}, + c: {} + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesTypeChange() { + final a = {x: '10'}; + final b = {x: 10}; + final diff = new Diff(a, b); + final expected = { + "a": {}, + "d": {}, + "c": {"x": Json.parse('["10", 10]')} + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesSimpleChange() { + final a = {x: 'Hello'}; + final b = {x: 'World'}; + final diff = new Diff(a, b); + final expected = { + a: {}, + d: {}, + c: {x: ['Hello', 'World']} + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesObjectAddition() { + final a = {x: {y: 'Hello'}}; + final b = {x: {y: 'Hello', z: 'World'}}; + final diff = new Diff(a, b); + final expected = { + a: {}, + d: {}, + c: { + x: { + a: {z: 'World'}, + d: {}, + c: {} + } + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesObjectDeletion() { + final a = {x: {y: 'Hello', z: 'World'}}; + final b = {x: {y: 'Hello'}}; + final diff = new Diff(a, b); + final expected = { + a: {}, + d: {}, + c: { + x: { + a: {}, + d: {z: 'World'}, + c: {} + } + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesObjectChange() { + final a = {x: {y: 'Hello', z: 'Hi', w: 'Other'}}; + final b = {x: {y: 'World', z: 'He', w: 'Other'}}; + final diff = new Diff(a, b); + final expected = { + a: {}, + d: {}, + c: { + x: { + a: {}, + d: {}, + c: { + y: ['Hello', 'World'], + z: ['Hi', 'He'] + } + } + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesArrayAdd() { + final a = {x: [10, 20]}; + final b = {x: [10, 20, 30]}; + final diff = new Diff(a, b); + final expected = { + a: {}, + d: {}, + c: { + x: [ + [30], + [], + [] + ] + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesArrayDelete() { + final a = {x: [10, 20, 30]}; + final b = {x: [10, 20]}; + final diff = new Diff(a, b); + final expected = { + a: {}, + d: {}, + c: { + x: [ + [], + [30], + [] + ] + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesArraySimpleChange() { + final a = {x: [10, 20]}; + final b = {x: [10, 30]}; + final diff = new Diff(a, b); + final expected = { + "a": {}, + "d": {}, + "c": { + "x": [ + [], + [], + [ + [1, 20, 30] + ] + ] + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesArraySimpleChangeAndDelete() { + final a = {x: [10, 20, 100]}; + final b = {x: [10, 30]}; + final diff = new Diff(a, b); + final expected = { + "a": {}, + "d": {}, + "c": { + "x": [ + [], + [100], + Json.parse('[[1, 20, 30]]') + ] + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesArrayArrayChange() { + final a = Json.parse('{"x": [10, [20]]}'); + final b = Json.parse('{"x": [10, [30]]}'); + final diff = new Diff(a, b); + final expected = { + "a": {}, + "d": {}, + "c": { + "x": [ + [], + [], + [Json.parse('[1, [[], [], [[0, 20, 30]]]]')] + ] + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testChangesArrayObjectChange() { + final a = Json.parse('{"x": [10, {"y": 20}]}'); + final b = Json.parse('{"x": [10, {"y": 30}]}'); + final diff = new Diff(a, b); + final expected = { + "a": {}, + "d": {}, + "c": { + "x": [ + [], + [], + [Json.parse('[1, {"a": {}, "d": {}, "c": {"y": [20, 30]}}]')] + ] + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testComplexObjects() { + final first = { + title: 'Hello', + forkCount: 20, + stargazers: ['/users/20', '/users/30'], + settings: { + assignees: [100, 101, 201] + } + }; + final second = { + title: 'Hellooo', + forkCount: 20, + stargazers: ['/users/20', '/users/30', '/users/40'], + settings: { + assignees: [100, 101, 202] + } + }; + final diff = new Diff(first, second); + final expected = { + a: {}, + d: {}, + c: { + title: ['Hello', 'Hellooo'], + stargazers: [ + ['/users/40'], + [], + [] + ], + settings: { + a: {}, + d: {}, + c: { + assignees: [ + [], + [], + [ + [2, 201, 202] + ] + ] + } + } + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), diff.toString())); + } + + @Test + public function testApplySame() { + final obj = {x: 'Hello'}; + final diff = new Diff(obj, obj); + Assert.isTrue(areJsonEqual(Json.stringify(obj), Json.stringify(diff.apply(obj)))); + } + + @Test + public function testApplyWithAddition() { + final a = {x: 'Hello'}; + final b = {x: 'Hello', y: 'World'}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyWithDeletion() { + final a = {x: 'Hello', y: 'World'}; + final b = {y: 'World'}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyWithSimpleChange() { + final a = {x: 'Hello'}; + final b = {x: 'World'}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyWithObjectAddition() { + final a = {x: {y: 'Hello'}}; + final b = {x: {y: 'Hello', z: 'World'}}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyWithObjectChange() { + final a = {x: {y: 'Hello', z: 'Hi', w: 'Other'}}; + final b = {x: {y: 'World', z: 'He', w: 'Other'}}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyWithObjectDeletion() { + final a = {x: {y: 'Hello', z: 'World'}}; + final b = {x: {y: 'Hello'}}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyWithArrayAddition() { + final a = {x: [10, 20]}; + final b = {x: [10, 20, 30]}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyWithArrayDeletion() { + final a = {x: [10, 20, 30]}; + final b = {x: [10, 20]}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyWithArraySimpleChange() { + final a = {x: [10, 20]}; + final b = {x: [10, 30]}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyWithArrayArrayChange() { + final a = Json.parse('{"x": [10, [20]]}'); + final b = Json.parse('{"x": [10, [30]]}'); + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyWithArrayObjectChange() { + final a = Json.parse('{"x": [10, {"y": 20}]}'); + final b = Json.parse('{"x": [10, {"y": 30}]}'); + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testApplyComplex() { + final a = { + title: 'Hello', + forkCount: 20, + stargazers: ['/users/20', '/users/30'], + settings: { + assignees: [100, 101, 201] + } + }; + final b = { + title: 'Hellooo', + forkCount: 20, + stargazers: ['/users/20', '/users/30', '/users/40'], + settings: { + assignees: [100, 101, 202] + } + }; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(b), Json.stringify(diff.apply(a)))); + } + + @Test + public function testSwapSame() { + final obj = {x: 'Hello'}; + final diff = new Diff(obj, obj); + Assert.isTrue(areJsonEqual(Json.stringify(obj), Json.stringify(diff.swap().apply(obj)))); + } + + @Test + public function testSwapWithAddition() { + final a = {x: 'Hello'}; + final b = {x: 'Hello', y: 'World'}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapWithDeletion() { + final a = {x: 'Hello', y: 'World'}; + final b = {x: 'Hello'}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapWithSimpleChange() { + final a = {x: 'Hello'}; + final b = {x: 'World'}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapWithObjectAddition() { + final a = {x: {y: 'Hello'}}; + final b = {x: {y: 'Hello', z: 'World'}}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapWithObjectChange() { + final a = {x: {y: 'Hello', z: 'Hi', w: 'Other'}}; + final b = {x: {y: 'World', z: 'He', w: 'Other'}}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapWithObjectDeletion() { + final a = {x: {y: 'Hello', z: 'World'}}; + final b = {x: {y: 'Hello'}}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapWithArrayAddition() { + final a = {x: [10, 20]}; + final b = {x: [10, 20, 30]}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapWithArrayDeletion() { + final a = {x: [10, 20, 30]}; + final b = {x: [10, 20]}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapWithArraySimpleChange() { + final a = {x: [10, 20]}; + final b = {x: [10, 30]}; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapWithArrayArrayChange() { + final a = Json.parse('{"x": [10, [20]]}'); + final b = Json.parse('{"x": [10, [30]]}'); + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapWithArrayObjectChange() { + final a = Json.parse('{"x": [10, {"y": 20}]}'); + final b = Json.parse('{"x": [10, {"y": 30}]}'); + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testSwapComplex() { + final a = { + title: 'Hello', + forkCount: 20, + stargazers: ['/users/20', '/users/30'], + settings: { + assignees: [100, 101, 201] + } + }; + final b = { + title: 'Hellooo', + forkCount: 20, + stargazers: ['/users/20', '/users/30', '/users/40'], + settings: { + assignees: [100, 101, 202] + }, + }; + final diff = new Diff(a, b); + Assert.isTrue(areJsonEqual(Json.stringify(a), Json.stringify(diff.swap().apply(b)))); + } + + @Test + public function testBuildWithAdditions() { + final a = {x: 'Hello'}; + final b = {x: 'Hello', y: 'World'}; + final diff = new Diff(a, b); + final expected = {y: 'World'}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithDeletions() { + final a = {x: 'Hello', y: 'World'}; + final b = {y: 'World'}; + final diff = new Diff(a, b); + final expected = {}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesNoChange() { + final a = {x: 'Hello'}; + final b = {x: 'Hello'}; + final diff = new Diff(a, b); + final expected = {}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesTypeChange() { + final a = {x: '10'}; + final b = {x: 10}; + final diff = new Diff(a, b); + final expected = {x: 10}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesSimpleChange() { + final a = {x: 'Hello'}; + final b = {x: 'World'}; + final diff = new Diff(a, b); + final expected = {x: 'World'}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesObjectAddition() { + final a = {x: {y: 'Hello'}}; + final b = {x: {y: 'Hello', z: 'World'}}; + final diff = new Diff(a, b); + final expected = {x: {z: 'World'}}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesObjectDeletion() { + final a = {x: {y: 'Hello', z: 'World'}}; + final b = {x: {y: 'Hello'}}; + final diff = new Diff(a, b); + final expected = {x: {}}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesObjectChange() { + final a = {x: {y: 'Hello', z: 'Hi', w: 'Other'}}; + final b = {x: {y: 'World', z: 'He', w: 'Other'}}; + final diff = new Diff(a, b); + final expected = {x: {y: 'World', z: 'He'}}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesArrayAdd() { + final a = {x: [10, 20]}; + final b = {x: [10, 20, 30]}; + final diff = new Diff(a, b); + final expected = {x: [30]}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesArrayDelete() { + final a = {x: [10, 20, 30]}; + final b = {x: [10, 20]}; + final diff = new Diff(a, b); + final expected = {x: []}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesArraySimpleChange() { + final a = {x: [10, 20]}; + final b = {x: [10, 30]}; + final diff = new Diff(a, b); + #if !cpp + final expected = {x: [null, 30]}; + #else + final expected = {x: [0, 30]}; + #end + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesArraySimpleChangeAndDelete() { + final a = {x: [10, 20, 100]}; + final b = {x: [10, 30]}; + final diff = new Diff(a, b); + #if !cpp + final expected = {x: [null, 30]}; + #else + final expected = {x: [0, 30]}; + #end + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesArrayArrayChange() { + final a = Json.parse('{"x": [10, [20]]}'); + final b = Json.parse('{"x": [10, [30]]}'); + final diff = new Diff(a, b); + final expected = {x: [null, [30]]}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithChangesArrayObjectChange() { + final a = Json.parse('{"x": [10, {"y": 20}]}'); + final b = Json.parse('{"x": [10, {"y": 30}]}'); + final diff = new Diff(a, b); + final expected = {x: [null, {y: 30}]}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithComplexObjects() { + final first = { + title: 'Hello', + forkCount: 20, + stargazers: ['/users/20', '/users/30'], + settings: { + assignees: [100, 101, 201] + } + }; + final second = { + title: 'Hellooo', + forkCount: 20, + stargazers: ['/users/20', '/users/30', '/users/40'], + settings: { + assignees: [100, 101, 202] + } + }; + final diff = new Diff(first, second); + final expected = { + title: 'Hellooo', + stargazers: ['/users/40'], + settings: { + #if !cpp + assignees: [null, null, 202] + #else + assignees: [0, 0, 202] + #end + } + }; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({})))); + } + + @Test + public function testBuildWithAddedId() { + final a = {x: 'Hello'}; + final b = {x: 'Hello', y: 'World'}; + final diff = new Diff(a, b); + final expected = {id: 'Identifier', y: 'World'}; + Assert.isTrue(areJsonEqual(Json.stringify(expected), Json.stringify(diff.apply({id: 'Identifier'})))); + } + + @Test + public function testNoObj() { + final a = 0; + final b = {x: 'Hello', y: 'World'}; + try { + new Diff(a, b); + Assert.fail('We should not get here'); + } catch (ex: Dynamic) {} + } + + @Test + public function testNoObj2() { + final a = {x: 'Hello'}; + final b = 0; + try { + new Diff(a, b); + Assert.fail('We should not get here'); + } catch (ex: Dynamic) {} + } + + private static function areJsonEqual(a:String, b:String):Bool { + return areObjectsEqual(Json.parse(a), Json.parse(b)); + } + + private static function areObjectsEqual(a:Dynamic, b:Dynamic):Bool { + final fieldsA = Reflect.fields(a); + final fieldsB = Reflect.fields(b); + return areFieldNamesEqual(fieldsA, fieldsB) && areFieldValuesEqual(fieldsA, a, b); + } + + private static function areFieldNamesEqual(fieldsA:Array, fieldsB:Array):Bool { + return (fieldsA.length == fieldsB.length) && Lambda.foreach(fieldsA, a -> fieldsB.contains(a)); + } + + private static function areFieldValuesEqual(fields:Array, a:Dynamic, b:Dynamic):Bool { + for (fieldName in fields) { + final fieldA:Dynamic = Reflect.field(a, fieldName); + final fieldB:Dynamic = Reflect.field(b, fieldName); + if (!areFieldTypesEqual(fieldA, fieldB) || !areEqual(fieldA, fieldB)) { + return false; + } + } + return true; + } + + private static function areFieldTypesEqual(fieldA:Dynamic, fieldB:Dynamic):Bool { + return Std.string(Type.typeof(fieldA)) == Std.string(Type.typeof(fieldB)); + } + + private static function areEqual(fieldA:Dynamic, fieldB:Dynamic):Bool { + switch (Type.typeof(fieldA)) { + case TObject: + return areObjectsEqual(fieldA, fieldB); + case TClass(Array): + return areArraysEqual(fieldA, fieldB); + default: + return fieldA == fieldB; + } + } + + private static function areArraysEqual(arrA:Array, arrB:Array):Bool { + return arrA.length == arrB.length + && Lambda.foreach([for (i in 0...arrA.length) i], i -> areEqual(arrA[i], arrB[i])); + } +} diff --git a/test/unit/TestSuite.hx b/test/unit/TestSuite.hx index b9f2479e..db59962e 100644 --- a/test/unit/TestSuite.hx +++ b/test/unit/TestSuite.hx @@ -24,6 +24,7 @@ import CustomLoggerFormatterTest; import FlowTest; import ListingRequestTest; import SubscriptionRequestTest; +import DiffTest; import ItemTest; import MarketplaceTest; import TierAccountTest; @@ -63,6 +64,7 @@ class TestSuite extends massive.munit.TestSuite add(FlowTest); add(ListingRequestTest); add(SubscriptionRequestTest); + add(DiffTest); add(ItemTest); add(MarketplaceTest); add(TierAccountTest); diff --git a/unittests.hxml b/unittests.hxml index b0fabad4..9034750a 100644 --- a/unittests.hxml +++ b/unittests.hxml @@ -1,6 +1,5 @@ ## JavaScript -main TestMain --lib diff:1.0.0 -lib munit #-lib hamcrest -lib hxnodejs @@ -12,7 +11,6 @@ ## Neko --next -main TestMain --lib diff:1.0.0 -lib munit #-lib hamcrest -cp test/unit @@ -23,7 +21,6 @@ ## CPP --next -main TestMain --lib diff:1.0.0 -lib munit #-lib hamcrest -cp test/unit @@ -35,7 +32,6 @@ ## Java --next -main TestMain --lib diff:1.0.0 -lib munit #-lib hamcrest -cp test/unit @@ -46,7 +42,6 @@ ## CSharp --next -main TestMain --lib diff:1.0.0 -lib munit #-lib hamcrest -cp test/unit @@ -57,7 +52,6 @@ ## Python --next -main TestMain --lib diff:1.0.0 -lib munit #-lib hamcrest -cp test/unit @@ -68,7 +62,6 @@ ## PHP 7 --next -main TestMain --lib diff:1.0.0 -lib munit #-lib hamcrest -cp test/unit @@ -80,7 +73,6 @@ ## HashLink --next -main TestMain --lib diff:1.0.0 -lib munit #-lib hamcrest -cp test/unit