├── .gitignore ├── .github └── workflows │ └── publish-npm.yml ├── package.json ├── README.md ├── tests.js ├── index.js └── test └── index.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | node_modules -------------------------------------------------------------------------------- /.github/workflows/publish-npm.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | jobs: 4 | publish: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | - uses: actions/setup-node@v1 9 | with: 10 | node-version: 10 11 | - run: npm install 12 | - run: npm test 13 | - uses: JS-DevTools/npm-publish@v1 14 | with: 15 | token: ${{ secrets.NPM_TOKEN }} 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json0-ot-diff", 3 | "version": "1.1.2", 4 | "description": "Finds differences between two JSON object and generates operational transformation (OT) operations for transforming the first object into the second.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node tests.js", 8 | "mocha": "mocha", 9 | "mocha-watch": "mocha --watch" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/kbadk/json0-ot-diff.git" 14 | }, 15 | "keywords": [ 16 | "json", 17 | "json0", 18 | "ot", 19 | "operational", 20 | "transformation", 21 | "diff" 22 | ], 23 | "author": "Kristian Antonsen", 24 | "license": "Apache-2.0", 25 | "bugs": { 26 | "url": "https://github.com/kbadk/json0-ot-diff/issues" 27 | }, 28 | "homepage": "https://github.com/kbadk/json0-ot-diff#readme", 29 | "dependencies": { 30 | "deep-equal": "^1.0.1" 31 | }, 32 | "devDependencies": { 33 | "chai": "^4.3.7", 34 | "clone": "^2.1.2", 35 | "diff-match-patch": "^1.0.5", 36 | "mocha": "^10.2.0", 37 | "ot-json0": "^1.1.0", 38 | "ot-json1": "^1.0.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON0 OT Diff 2 | 3 | Finds differences between two JSON object and generates operational transformation (OT) operations for transforming the first object into the second according to the [JSON0 OT Type](https://github.com/ottypes/json0). 4 | 5 | The current implementation supports list/object insertion/deletion (i.e. `li`, `ld`, `oi`, `od`) out of the box. 6 | 7 | var jsondiff = require("./json0-ot-diff"); 8 | 9 | var diff = jsondiff( 10 | ["foo", "bar", 1, 2, 3], 11 | ["foo", "quux", 1, 2] 12 | ); 13 | console.log(diff); 14 | 15 | > [ 16 | > { p: [ 1 ], ld: 'bar', li: 'quux' }, 17 | > { p: [ 4 ], ld: 3 } 18 | >] 19 | 20 | String insertion/deletion (i.e. `si`, `sd`) operations are generated for string mutations if you provide a reference to [diff-match-patch](https://github.com/google/diff-match-patch). 21 | 22 | var diffMatchPatch = require("diff-match-patch"); 23 | var diff = jsondiff( 24 | ["foo", "The only change here is at the end.", 1, 2, 3], 25 | ["foo", "The only change here is at the very end.", 1, 2], 26 | diffMatchPatch 27 | ); 28 | console.log(diff); 29 | 30 | > [ 31 | > { p: [ 1, 31 ], si: 'very ' }, 32 | > { p: [ 4 ], ld: 3 } 33 | >] 34 | 35 | The [JSON1 OT Type](https://github.com/ottypes/json1) is supported as well. To generate ops for the JSON1 OT type, provide a reference to [diff-match-patch](https://github.com/google/diff-match-patch), [ot-json1](https://github.com/ottypes/json1) and [ot-text-unicode](https://github.com/ottypes/text-unicode). 36 | 37 | var diffMatchPatch = require("diff-match-patch"); 38 | var json1 = require("ot-json1"); 39 | var textUnicode = require("ot-text-unicode"); 40 | var diff = jsondiff( 41 | ["foo", "The only change here is at the end.", 1, 2, 3], 42 | ["foo", "The only change here is at the very end.", 1, 2], 43 | diffMatchPatch, 44 | json1, 45 | textUnicode 46 | ); 47 | console.log(diff); 48 | 49 | >[ 50 | > [ 1, { "es": [ 31, "very " ] } ], 51 | > [ 4, { "r": true } ] 52 | >] 53 | 54 | This was developed for [JsonML](http://www.jsonml.org/) with [Webstrates](https://github.com/cklokmose/Webstrates) in mind, but could be applicable in other situations. 55 | -------------------------------------------------------------------------------- /tests.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var assert = require("assert"); 4 | var equal = require("deep-equal"); 5 | var clone = require("clone"); 6 | var json0 = require("ot-json0"); 7 | let json1 = require("ot-json1"); 8 | let diffMatchPatch = require("diff-match-patch"); 9 | let textUnicode = require("ot-text-unicode"); 10 | var jsondiff = require("./index.js"); 11 | 12 | var tests = [ 13 | // Tests of equality 14 | [ 15 | 5, 16 | 5, 17 | ], 18 | [ 19 | "foo", 20 | "foo", 21 | ], 22 | [ 23 | ["foo"], 24 | ["foo"], 25 | ], 26 | [ 27 | [], 28 | [], 29 | ], 30 | [ 31 | {}, 32 | {}, 33 | ], 34 | // Tests of li/ld 35 | [ 36 | [], 37 | ["foo"] 38 | ], 39 | [ 40 | ["foo"], 41 | ["bar"] 42 | ], 43 | [ 44 | ["foo", "bar"], 45 | ["bar"] 46 | ], 47 | [ 48 | ["foo", "bar", "quux"], 49 | ["bar"] 50 | ], 51 | [ 52 | [["foo", "bar"], "bar"], 53 | ["bar", "bar"] 54 | ], 55 | [ 56 | [["foo", "bar"], "bar"], 57 | [] 58 | ], 59 | [ 60 | [[["foo"], "bar"], "quux"], 61 | ["quux"] 62 | ], 63 | [ 64 | ["foo", "bar", "quux"], 65 | ["bar", "quux"] 66 | ], 67 | // Tests for object/array 68 | [ 69 | {}, 70 | [] 71 | ], 72 | [ 73 | [], 74 | {} 75 | ], 76 | // Tests for oi/od 77 | [ 78 | {}, 79 | {"foo":"bar"} 80 | ], 81 | [ 82 | {"foo":"bar"}, 83 | {"foo":"quux"} 84 | ], 85 | [ 86 | [ { foo: 'bar' } ], 87 | [ {} ] 88 | ], 89 | // Null tests 90 | [null, null], 91 | [null, "foo"], 92 | [null, 123], 93 | ["foo", null], 94 | [123, null], 95 | [null, {}], 96 | [{}, null], 97 | // String tests 98 | // Inspired by https://github.com/google/diff-match-patch/blob/master/javascript/tests/diff_match_patch_test.js 99 | ["abc", "xyz"], 100 | ["1234abcdef", "1234xyz"], 101 | ["1234", "1234xyz"], 102 | ["abc", "xyz"], 103 | ["abcdef1234", "xyz1234"], 104 | ["1234", "xyz1234"], 105 | ["", "abcd"], 106 | ["abc", "abcd"], 107 | ["123456", "abcd"], 108 | ["123456xxx", "xxxabcd"], 109 | ["fi", "\ufb01i"], 110 | ["1234567890", "abcdef"], 111 | ["12345", "23"], 112 | ["1234567890", "a345678z"], 113 | ["a345678z", "1234567890"], 114 | ["abc56789z", "1234567890"], 115 | ["a23456xyz", "1234567890"], 116 | ["121231234123451234123121", "a1234123451234z"], 117 | ["x-=-=-=-=-=-=-=-=-=-=-=-=", "xx-=-=-=-=-=-=-="], 118 | ["-=-=-=-=-=-=-=-=-=-=-=-=y", "-=-=-=-=-=-=-=yy"], 119 | ["qHilloHelloHew", "xHelloHeHulloy"], 120 | ["abcdefghijk", "fgh"], 121 | ["abcdefghijk", "efxhi"], 122 | ["abcdefghijk", "cdefxyhijk"], 123 | ["abcdefghijk", "bxy"], 124 | ["123456789xx0", "3456789x0"], 125 | ["abcdefghijk", "efxyhi"], 126 | ["abcdefghijk", "bcdef"], 127 | ["abcdexyzabcde", "abccde"], 128 | ["abcdefghijklmnopqrstuvwxyz01234567890", "XabXcdXefXghXijXklXmnXopXqrXstXuvXwxXyzX01X23X45X67X89X0"], 129 | ["abcdef1234567890123456789012345678901234567890123456789012345678901234567890uvwxyz", "abcdefuvwxyz"], 130 | ["1234567890123456789012345678901234567890123456789012345678901234567890", "abc"], 131 | ["XY", "XtestY"], 132 | ["XXXXYYYY", "XXXXtestYYYY"], 133 | ["The quick brown fox jumps over the lazy dog.", "Woof"], 134 | // Big tests 135 | [ 136 | [], 137 | ["the", {"quick":"brown", "fox":"jumped"}, "over", {"the": ["lazy", "dog"]}] 138 | ], 139 | [ 140 | ["the", {"quick":"brown", "fox":"jumped"}, "over", {"the": ["lazy", "dog"]}], 141 | [] 142 | ], 143 | [ 144 | [["the", {"quick":"black", "fox":"jumped"}, "over", {"the": ["lazy", "dog"]}]], 145 | ["the", {"quick":"brown", "fox":"leapt"}, "over", {"the": ["stupid", "dog"]}] 146 | ], 147 | // Real-life jsonml tests 148 | [ 149 | [ 'html', {}, [ 'body', {}, '\n\n', '\n\n', [ 'p', {}, 'Quux!' ] ], '\n' ], 150 | [ 'html', {}, [ 'body', {}, '\n\n', '\n\n\n\n', [ 'p', {}, 'Quux!' ] ], '\n' ] 151 | ], 152 | [ 153 | [ 'html', {}, [ 'body', {}, '\n\n', '\n\n', [ 'p', {}, 'Quux!' ] ], '\n' ], 154 | [ 'html', {}, [ 'body', {}, '\n\n\n\n', [ 'p', {}, 'Quux!' ] ], '\n' ] 155 | ], 156 | [ 157 | ["html", {}, 158 | ["body", {}, 159 | "foo", ["b", {}, "hello"], 160 | "foo", ["b", {}], ["strong", {}, "bar"] 161 | ] 162 | ], 163 | ["html", {}, 164 | ["body", {}, 165 | "foo", ["b", {}], ["strong", {}, "bar"], 166 | ["p", {}] 167 | ] 168 | ] 169 | ], 170 | [ 171 | ["html", {}, 172 | "\n", 173 | ["body", {"contenteditable":""}, 174 | "\n", 175 | ["div", {}, "a"], 176 | "\n", 177 | ["div", {}, "b"], 178 | "\n", 179 | ["div", {}, "c"], 180 | "\n" 181 | ], 182 | "\n" 183 | ], 184 | ["html", {}, 185 | "\n", 186 | ["body", {"contenteditable":""}, 187 | "\n", 188 | ["div", {}, "b"], 189 | "\n", 190 | ["div", {}, "c"], 191 | "\n" 192 | ], 193 | "\n" 194 | ] 195 | ] 196 | ]; 197 | 198 | // Test whether jsondiff modifies the input/output (it shouldn't). 199 | tests.forEach(function([input, output]) { 200 | var cinput = clone(input), coutput = clone(output); 201 | jsondiff(input, output); 202 | assert(equal(cinput, input)); 203 | assert(equal(coutput, output)); 204 | }); 205 | 206 | // Actual tests for json0 207 | tests.forEach(function([input, output]) { 208 | var ops = jsondiff(input, output); 209 | 210 | // Don't let json0 mutate the input, 211 | // so we can use it in later tests. 212 | input = clone(input); 213 | ops.forEach(function(op) { 214 | assert.doesNotThrow(function() { 215 | input = json0.type.apply(input, [op]); 216 | }, null, "json0 could not apply transformation"); 217 | }); 218 | assert(equal(input, output)); 219 | }); 220 | 221 | // Actual tests for json1 222 | tests.forEach(function([input, output]) { 223 | var ops = jsondiff( 224 | input, 225 | output, 226 | diffMatchPatch, 227 | json1, 228 | textUnicode 229 | ); 230 | assert(equal(json1.type.apply(input, ops), output)); 231 | }); 232 | 233 | console.log("No errors!"); 234 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var equal = require("deep-equal"); 4 | 5 | var diffMatchPatchInstance; 6 | 7 | function replaceOp(path, isObject, input, output, json1) { 8 | var op; 9 | if (json1) { 10 | op = json1.replaceOp(path, input, output); 11 | } else { 12 | op = { p: path }; 13 | op[isObject ? "od" : "ld"] = input; 14 | op[isObject ? "oi" : "li"] = output; 15 | } 16 | return [op]; 17 | } 18 | 19 | /** 20 | * Convert a number of string patches to OT operations. 21 | * @param {JsonMLPath} path Base path for patches to apply to. 22 | * @param {string} oldValue Old value. 23 | * @param {string} newValue New value. 24 | * @return {Ops} List of resulting operations. 25 | */ 26 | function patchesToOps(path, oldValue, newValue, diffMatchPatch, json1, textUnicode) { 27 | const ops = []; 28 | 29 | var patches = diffMatchPatchInstance.patch_make(oldValue, newValue); 30 | 31 | Object.keys(patches).forEach(function(i) { 32 | var patch = patches[i], offset = patch.start1; 33 | patch.diffs.forEach(function([type, value]) { 34 | switch (type) { 35 | case diffMatchPatch.DIFF_DELETE: 36 | if (textUnicode) { 37 | var unicodeOp = textUnicode.remove(offset, value); 38 | ops.push(json1.editOp(path, textUnicode.type, unicodeOp)); 39 | } else { 40 | ops.push({ sd: value, p: [...path, offset] }); 41 | } 42 | break; 43 | case diffMatchPatch.DIFF_INSERT: 44 | if (textUnicode) { 45 | var unicodeOp = textUnicode.insert(offset, value); 46 | ops.push(json1.editOp(path, textUnicode.type, unicodeOp)); 47 | } else { 48 | ops.push({ si: value, p: [...path, offset] }); 49 | } 50 | // falls through intentionally 51 | case diffMatchPatch.DIFF_EQUAL: 52 | offset += value.length; 53 | break; 54 | default: throw Error(`Unsupported operation type: ${type}`); 55 | } 56 | }); 57 | }); 58 | 59 | return ops; 60 | } 61 | 62 | var optimize = function(ops, options) { 63 | if (options && options.json1) { 64 | var compose = options.json1.type.compose; 65 | return ops.reduce(compose, null); 66 | } 67 | /* 68 | Optimization loop where we attempt to find operations that needlessly inserts and deletes identical objects right 69 | after each other, and then consolidate them. 70 | */ 71 | for (var i=0, l=ops.length-1; i < l; ++i) { 72 | var a = ops[i], b = ops[i+1]; 73 | 74 | // The ops must have same path. 75 | if (!equal(a.p.slice(0, -1), b.p.slice(0, -1))) { 76 | continue; 77 | } 78 | 79 | // The indices must be successive. 80 | if (a.p[a.p.length-1] + 1 !== b.p[b.p.length-1]) { 81 | continue; 82 | } 83 | 84 | // The first operatin must be an insertion and the second a deletion. 85 | if (!a.li || !b.ld) { 86 | continue; 87 | } 88 | 89 | // The object we insert must be equal to what we delete next. 90 | if (!equal(a.li, b.ld)) { 91 | continue; 92 | } 93 | 94 | delete a.li; 95 | delete b.ld; 96 | } 97 | 98 | ops = ops.filter(function(op) { 99 | return Object.keys(op).length > 1; 100 | }); 101 | 102 | return ops; 103 | } 104 | 105 | var diff = function(input, output, path=[], options) { 106 | var diffMatchPatch = options && options.diffMatchPatch; 107 | var json1 = options && options.json1; 108 | var textUnicode = options && options.textUnicode; 109 | 110 | // If the last element of the path is a string, that means we're looking at a key, rather than 111 | // a number index. Objects use keys, so the target for our insertion/deletion is an object. 112 | var isObject = typeof path[path.length-1] === "string" || path.length === 0; 113 | 114 | // If input and output are equal, no operations are needed. 115 | if (equal(input, output) && Array.isArray(input) === Array.isArray(output)) { 116 | return []; 117 | } 118 | 119 | // If there is no output, we need to delete the current data (input). 120 | if (typeof output === "undefined") { 121 | var op; 122 | if (json1) { 123 | op = json1.removeOp(path, input); 124 | } else { 125 | op = { p: path }; 126 | op[isObject ? "od" : "ld"] = input; 127 | } 128 | return [op]; 129 | } 130 | 131 | // If there is no input, we need to add the new data (output). 132 | if (typeof input === "undefined") { 133 | var op; 134 | if (json1) { 135 | op = json1.insertOp(path, output); 136 | } else { 137 | op = { p: path }; 138 | op[isObject ? "oi" : "li"] = output; 139 | } 140 | return [op]; 141 | } 142 | 143 | // If input or output is null, we need to delete it then add new data. 144 | if (input === null || output === null) { 145 | var ops = []; 146 | if (json1) { 147 | ops.push(json1.removeOp(path, input)); 148 | ops.push(json1.insertOp(path, output)); 149 | } else { 150 | var op_delete = { p: path }; 151 | op_delete[isObject ? "od" : "ld"] = input; 152 | var op_insert = { p: path }; 153 | op_insert[isObject ? "oi" : "li"] = output; 154 | ops = [op_delete, op_insert]; 155 | } 156 | return ops; 157 | } 158 | 159 | // If diffMatchPatch was provided, handle string mutation. 160 | if (diffMatchPatch && (typeof input === "string") && (typeof output === "string")) { 161 | 162 | // Instantiate the instance of diffMatchPatch only once. 163 | if (!diffMatchPatchInstance) { 164 | diffMatchPatchInstance = new diffMatchPatch(); 165 | } 166 | 167 | return patchesToOps(path, input, output, diffMatchPatch, json1, textUnicode); 168 | } 169 | 170 | var primitiveTypes = ["string", "number", "boolean"]; 171 | // If either of input/output is a primitive type, there is no need to perform deep recursive calls to 172 | // figure out what to do. We can just replace the objects. 173 | if (primitiveTypes.includes(typeof output) || primitiveTypes.includes(typeof input)) { 174 | return replaceOp(path, isObject, input, output, json1); 175 | } 176 | 177 | if (Array.isArray(output) && Array.isArray(input)) { 178 | var ops = []; 179 | var inputLen = input.length, outputLen = output.length; 180 | var minLen = Math.min(inputLen, outputLen); 181 | var ops = []; 182 | for (var i=0; i < minLen; ++i) { 183 | var newOps = diff(input[i], output[i], [...path, i], options); 184 | newOps.forEach(function(op) { 185 | ops.push(op); 186 | }); 187 | } 188 | if (outputLen > inputLen) { 189 | // deal with array insert 190 | for (var i=minLen; i < outputLen; i++) { 191 | var newOps = diff(undefined, output[i], [...path, i], options); 192 | newOps.forEach(function(op) { 193 | ops.push(op); 194 | }); 195 | } 196 | } else if (outputLen < inputLen) { 197 | // deal with array delete 198 | for (var i=minLen; i < inputLen; i++) { 199 | var newOps = diff(input[i], undefined, [...path, minLen], options); 200 | newOps.forEach(function(op) { 201 | ops.push(op); 202 | }); 203 | } 204 | } 205 | return ops; 206 | } else if (Array.isArray(output) || Array.isArray(input)) { 207 | // deal with array/object 208 | return replaceOp(path, isObject, input, output, json1); 209 | } 210 | 211 | var ops = []; 212 | var keys = new Set([...Object.keys(input), ...Object.keys(output)]); 213 | keys.forEach(function(key) { 214 | var newOps = diff(input[key], output[key], [...path, key], options); 215 | ops = ops.concat(newOps); 216 | }); 217 | return ops; 218 | } 219 | 220 | var optimizedDiff = function(input, output, diffMatchPatch, json1, textUnicode) { 221 | var options = { diffMatchPatch, json1, textUnicode }; 222 | return optimize(diff(input, output, [], options), options); 223 | } 224 | 225 | module.exports = optimizedDiff; 226 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Mocha assertions 4 | let assert = require("assert"); 5 | // Library we're testing 6 | let jsondiff = require("../index.js"); 7 | // OT types. 8 | // These are applied to work out if the right transform is being created 9 | let json0 = require("ot-json0"); 10 | let json1 = require("ot-json1"); 11 | let textUnicode = require("ot-text-unicode"); 12 | // Assertion expectations 13 | let expect = require("chai").expect; 14 | // Library for computing differences between strings 15 | let diffMatchPatch = require("diff-match-patch"); 16 | 17 | let clone = function(object) { 18 | return JSON.parse(JSON.stringify(object)); 19 | } 20 | 21 | describe("Jsondiff", function() { 22 | describe("Operations", function() { 23 | describe("List Insert (li)", function() { 24 | let tests = [ 25 | { 26 | name: "Add one string to empty array", 27 | start: [], 28 | end: ["one"], 29 | expectedCommand: [ 30 | { 31 | p: [0], 32 | li: "one" 33 | } 34 | ] 35 | }, 36 | { 37 | name: "Add one number to empty array", 38 | start: [], 39 | end: [1], 40 | expectedCommand: [ 41 | { 42 | p: [0], 43 | li: 1 44 | } 45 | ] 46 | }, 47 | { 48 | name: "Add one boolean to empty array", 49 | start: [], 50 | end: [false], 51 | expectedCommand: [ 52 | { 53 | p: [0], 54 | li: false 55 | } 56 | ] 57 | }, 58 | { 59 | name: "Add one string to end of non-empty array", 60 | start: ["one"], 61 | end: ["one", "two"], 62 | expectedCommand: [ 63 | { 64 | p: [1], 65 | li: "two" 66 | } 67 | ] 68 | }, 69 | { 70 | name: "Add one number to end of non-empty array", 71 | start: [1], 72 | end: [1, 2], 73 | expectedCommand: [ 74 | { 75 | p: [1], 76 | li: 2 77 | } 78 | ] 79 | }, 80 | { 81 | name: "Add one boolean to end of non-empty array", 82 | start: [false], 83 | end: [false, true], 84 | expectedCommand: [ 85 | { 86 | p: [1], 87 | li: true 88 | } 89 | ] 90 | } 91 | ]; 92 | runTests(tests); 93 | }); 94 | describe("List Replace (oi + od)", function() { 95 | let tests = [ 96 | { 97 | name: "Add one string to middle of array", 98 | start: ["one", "two"], 99 | end: ["one", "three", "two"], 100 | expectedCommand: [ 101 | { sd: 'wo', p: [ 1, 1 ] }, 102 | { si: 'hree', p: [ 1, 1 ] }, 103 | { p: [ 2 ], li: 'two' } 104 | ] 105 | }, 106 | { 107 | name: "Add one number to middle of array", 108 | start: [1, 2], 109 | end: [1, 3, 2], 110 | expectedCommand: [ 111 | { 112 | p: [1], 113 | ld: 2, 114 | li: 3 115 | }, 116 | { 117 | p: [2], 118 | li: 2 119 | } 120 | ] 121 | }, 122 | { 123 | name: "Add one boolean to middle of array", 124 | start: [false, false], 125 | end: [false, true, false], 126 | expectedCommand: [ 127 | { 128 | p: [1], 129 | ld: false, 130 | li: true 131 | }, 132 | { 133 | p: [2], 134 | li: false 135 | } 136 | ] 137 | } 138 | ]; 139 | runTests(tests); 140 | }); 141 | describe("Object Insert (oi)", function() { 142 | let tests = [ 143 | { 144 | name: "Add one string value to empty object", 145 | start: {}, 146 | end: { one: "two" }, 147 | expectedCommand: [ 148 | { 149 | p: ["one"], 150 | oi: "two" 151 | } 152 | ] 153 | }, 154 | { 155 | name: "Add one number value to empty object", 156 | start: {}, 157 | end: { one: 1 }, 158 | expectedCommand: [ 159 | { 160 | p: ["one"], 161 | oi: 1 162 | } 163 | ] 164 | }, 165 | { 166 | name: "Add one boolean value to empty object", 167 | start: {}, 168 | end: { one: true }, 169 | expectedCommand: [ 170 | { 171 | p: ["one"], 172 | oi: true 173 | } 174 | ] 175 | } 176 | ]; 177 | runTests(tests); 178 | }); 179 | describe("Object Replace (oi + od)", function() { 180 | let tests = [ 181 | { 182 | name: "Replaces one string value to empty object", 183 | start: { one: "one" }, 184 | end: { one: "two" }, 185 | expectedCommand: [ 186 | { sd: 'one', p: [ 'one', 0 ] }, 187 | { si: 'two', p: [ 'one', 0 ] } 188 | ] 189 | }, 190 | { 191 | name: "Replaces one number value to empty object", 192 | start: { one: 1 }, 193 | end: { one: 2 }, 194 | expectedCommand: [ 195 | { 196 | p: ["one"], 197 | oi: 2, 198 | od: 1 199 | } 200 | ] 201 | }, 202 | { 203 | name: "Replaces one boolean value to empty object", 204 | start: { one: true }, 205 | end: { one: false }, 206 | expectedCommand: [ 207 | { 208 | p: ["one"], 209 | oi: false, 210 | od: true 211 | } 212 | ] 213 | } 214 | ]; 215 | runTests(tests); 216 | }); 217 | describe("String Mutation (si + sd)", function() { 218 | // These test cases come from diff-match-patch tests. 219 | let tests = [ 220 | { 221 | name: "Top level string", 222 | start: "one", 223 | end: "two", 224 | expectedCommand: [ 225 | { sd: "one", p: [0] }, 226 | { si: "two", p: [0] } 227 | ] 228 | }, 229 | { 230 | name: "Strings with a common prefix, null case", 231 | start: { one: "one" }, 232 | end: { one: "two" }, 233 | expectedCommand: [ 234 | { sd: "one", p: [ "one", 0 ] }, 235 | { si: "two", p: [ "one", 0 ] } 236 | ] 237 | }, 238 | { 239 | name: "Strings with a common prefix, non-null case", 240 | start: { one: "1234abcdef" }, 241 | end: { one: "1234xyz" }, 242 | expectedCommand: [ 243 | { sd: 'abcdef', p: [ 'one', 4 ] }, 244 | { si: 'xyz', p: [ 'one', 4 ] } 245 | ] 246 | }, 247 | { 248 | name: "Strings with a common prefix, whole case", 249 | start: { one: "1234" }, 250 | end: { one: "1234xyz" }, 251 | expectedCommand: [ 252 | { si: 'xyz', p: [ 'one', 4 ] } 253 | ] 254 | }, 255 | { 256 | name: "Strings with a common suffix, non-null case", 257 | start: { one: "abcdef1234" }, 258 | end: { one: "xyz1234" }, 259 | expectedCommand: [ 260 | { sd: 'abcdef', p: [ 'one', 0 ] }, 261 | { si: 'xyz', p: [ 'one', 0 ] } 262 | ] 263 | }, 264 | { 265 | name: "Strings with a common suffix, whole case", 266 | start: { one: "1234" }, 267 | end: { one: "xyz1234" }, 268 | expectedCommand: [ 269 | { si: 'xyz', p: [ 'one', 0 ] } 270 | ] 271 | }, 272 | { 273 | name: "Strings suffix/prefix overlap, overlap case", 274 | start: { one: "123456xxx" }, 275 | end: { one: "xxxabcd" }, 276 | expectedCommand: [ 277 | { "p": [ "one", 0 ], "sd": "123456xxx" }, 278 | { "p": [ "one", 0 ], "si": "xxxabcd" } 279 | ] 280 | }, 281 | { 282 | name: "Example from README", 283 | start: ["foo", "The only change here is at the end.", 1, 2, 3], 284 | end: ["foo", "The only change here is at the very end.", 1, 2], 285 | expectedCommand: [ 286 | { p: [ 1, 31 ], si: "very " }, 287 | { p: [ 4 ], ld: 3 } 288 | ] 289 | } 290 | ]; 291 | runTests(tests); 292 | }); 293 | describe("Emoji", function() { 294 | let tests = [ 295 | { 296 | name: "Emoji replacement", 297 | start: { one: "1234abcdef" }, 298 | end: { one: "1234😃bcdef" }, 299 | expectedCommand: [ 300 | { sd: 'a', p: [ 'one', 4 ] }, 301 | { si: '😃', p: [ 'one', 4 ] } 302 | ] 303 | }, 304 | { 305 | name: "Emoji insertion", 306 | start: { one: "1234abcdef" }, 307 | end: { one: "1234😃abcdef" }, 308 | expectedCommand: [ 309 | { si: '😃', p: [ 'one', 4 ] } 310 | ] 311 | }, 312 | { 313 | name: "Emoji deletion", 314 | start: { one: "1234😃abcdef" }, 315 | end: { one: "1234abcdef" }, 316 | expectedCommand: [ 317 | { sd: '😃', p: [ 'one', 4 ] } 318 | ] 319 | }, 320 | { 321 | name: "Multiple emoji insertion", 322 | start: { one: "1234abcdef" }, 323 | end: { one: "1234😃😃abcdef" }, 324 | expectedCommand: [ 325 | { si: '😃😃', p: [ 'one', 4 ] } 326 | ] 327 | }, 328 | { 329 | name: "Multiple emoji deletion", 330 | start: { one: "1234😃😃abcdef" }, 331 | end: { one: "1234abcdef" }, 332 | expectedCommand: [ 333 | { sd: '😃😃', p: [ 'one', 4 ] } 334 | ] 335 | }, 336 | ]; 337 | runTests(tests); 338 | }); 339 | }); 340 | describe("JSON1", function () { 341 | describe("Reversible remove", function () { 342 | let tests = [ 343 | { 344 | name: "Remove last entry", 345 | start: [1, 2, 3], 346 | end: [1, 2], 347 | expectedCommand: [2, { r: 3 }], 348 | }, 349 | { 350 | name: "Remove middle entry", 351 | start: [1, 2, 3], 352 | end: [1, 3], 353 | expectedCommand: [ 354 | [1, { i: 3, r: 2 }], 355 | [2, { r: 3 }], 356 | ], 357 | }, 358 | { 359 | name: "Remove first entry", 360 | start: [1, 2, 3], 361 | end: [2, 3], 362 | expectedCommand: [ 363 | [0, { i: 2, r: 1 }], 364 | [1, { i: 3, r: 2 }], 365 | [2, { r: 3 }], 366 | ], 367 | }, 368 | { 369 | name: "Remove multiple", 370 | start: [1, 2, 3], 371 | end: [1], 372 | expectedCommand: [ 373 | [1, { r: 2 }], 374 | [2, { r: 3 }], 375 | ], 376 | }, 377 | { 378 | name: "Remove object", 379 | start: [1, 2, { a: 1 }], 380 | end: [1, 2], 381 | expectedCommand: [2, { r: { a: 1 } }], 382 | }, 383 | ]; 384 | 385 | tests.forEach((test) => { 386 | it(test.name, function () { 387 | let json1Op = jsondiff( 388 | test.start, 389 | test.end, 390 | diffMatchPatch, 391 | json1, 392 | textUnicode 393 | ); 394 | expect(json1Op).to.deep.equal(test.expectedCommand); 395 | let json1End = json1.type.apply(test.start, json1Op); 396 | expect(json1End).to.deep.equal(test.end); 397 | }); 398 | }); 399 | }); 400 | }); 401 | }); 402 | 403 | function runTests(tests) { 404 | tests.forEach(test => { 405 | it(test.name, function() { 406 | 407 | ////////////////// 408 | // Verify JSON0 // 409 | ////////////////// 410 | let json0Op = jsondiff(test.start, test.end, diffMatchPatch); 411 | expect(json0Op).to.deep.equal(test.expectedCommand); 412 | 413 | // Test actual application of the expected ops. 414 | // Clone the input, because json0 mutates the input to `apply`. 415 | let json0Start = clone(test.start); 416 | let json0End = json0.type.apply(json0Start, json0Op); 417 | expect(json0End).to.deep.equal(test.end); 418 | 419 | // Test application of ops generated _without_ diffMatchPatch. 420 | let json0SimpleOp = jsondiff(test.start, test.end); 421 | let json0SimpleStart = clone(test.start); 422 | let json0SimpleEnd = json0.type.apply(json0SimpleStart, json0SimpleOp); 423 | expect(json0SimpleEnd).to.deep.equal(test.end); 424 | 425 | 426 | ////////////////// 427 | // Verify JSON1 // 428 | ////////////////// 429 | let json1Op = jsondiff( 430 | test.start, 431 | test.end, 432 | diffMatchPatch, 433 | json1, 434 | textUnicode 435 | ); 436 | 437 | // Test actual application of the expected ops. 438 | // No need to clone the input, json1 does _not_ mutate the input to `apply`. 439 | let json1End = json1.type.apply(test.start, json1Op); 440 | expect(json1End).to.deep.equal(test.end); 441 | }); 442 | }); 443 | } 444 | --------------------------------------------------------------------------------