├── .gitignore ├── .travis.yml ├── README.md ├── lib ├── api.js ├── index.js └── text-tp2.js ├── package.json └── test ├── genOp.coffee ├── mocha.opts └── text-tp2.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.DS_Store 3 | node_modules 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 0.10 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TP2 Text OT type 2 | 3 | This is an implementation of OT for text which implements [transform property 4 | 2](http://en.wikipedia.org/wiki/Operational_transformation#Convergence_properties) 5 | through the use of tombstones. As such, this data structure can be used in 6 | peer-to-peer situations (with concurrency control algorithms which do not have 7 | a single source of truth). 8 | 9 | This code following this spec from lightwave: 10 | http://code.google.com/p/lightwave/source/browse/trunk/experimental/ot/README 11 | 12 | Documents are a string with tombstones inserted throughout. For example, 'some 13 | ', (2 tombstones), 'string'. Tombstones indicate positions where characters 14 | once existed. They are important for many parties to agree on convergence. 15 | 16 | This is encoded in a document as ['some ', (2 tombstones), 'string'] 17 | (It should be encoded as {s:'some string', t:[5, -2, 6]} because thats 18 | faster in JS, but its not.) 19 | 20 | Just like in the 'normal' [plaintext type](/ottypes/text), Ops are lists of 21 | components which iterate over the whole document. 22 | 23 | Components are either: 24 | 25 | Compoent | Description 26 | ---------- | ------------ 27 | `N` | Skip N characters in the original document 28 | `{i:'str'}`| Insert 'str' at the current position in the document 29 | `{i:N}` | Insert N tombstones at the current position in the document 30 | `{d:N}` | Delete (tombstone) N characters at the current position in the document 31 | 32 | For example: 33 | 34 | ``` 35 | [3, {i:'hi'}, 5, {d:8}] 36 | ``` 37 | 38 | Snapshots are lists with characters and tombstones. Characters are stored in strings 39 | and adjacent tombstones are flattened into numbers. 40 | 41 | Eg, the document: 'Hello .....world' ('.' denotes tombstoned (deleted) characters) 42 | would be represented by a document snapshot of ['Hello ', 5, 'world'] 43 | 44 | 45 | --- 46 | 47 | # License 48 | 49 | All code contributed to this repository is licensed under the standard MIT license: 50 | 51 | Copyright 2011 ottypes library contributors 52 | 53 | Permission is hereby granted, free of charge, to any person obtaining a copy 54 | of this software and associated documentation files (the "Software"), to deal 55 | in the Software without restriction, including without limitation the rights 56 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 57 | copies of the Software, and to permit persons to whom the Software is 58 | furnished to do so, subject to the following condition: 59 | 60 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 61 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 62 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 63 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 64 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 65 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 66 | THE SOFTWARE. 67 | 68 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | // Text document API for text-tp2 2 | 3 | var type = require('./text-tp2'); 4 | var takeDoc = type._takeDoc; 5 | var append = type._append; 6 | 7 | var appendSkipChars = function(op, doc, pos, maxlength) { 8 | while ((maxlength == null || maxlength > 0) && pos.index < doc.data.length) { 9 | var part = takeDoc(doc, pos, maxlength, true); 10 | if (maxlength != null && typeof part === 'string') { 11 | maxlength -= part.length; 12 | } 13 | append(op, part.length || part); 14 | } 15 | }; 16 | 17 | module.exports = api; 18 | function api(getSnapshot, submitOp) { 19 | return { 20 | // Number of characters in the string 21 | getLength: function() { return getSnapshot().charLength; }, 22 | 23 | // Flatten the document into a string 24 | get: function() { 25 | var snapshot = getSnapshot(); 26 | var strings = []; 27 | 28 | for (var i = 0; i < snapshot.data.length; i++) { 29 | var elem = snapshot.data[i]; 30 | if (typeof elem == 'string') { 31 | strings.push(elem); 32 | } 33 | } 34 | 35 | return strings.join(''); 36 | }, 37 | 38 | getText: function() { 39 | console.warn("`getText()` is deprecated; use `get()` instead."); 40 | return this.get(); 41 | }, 42 | 43 | // Insert text at pos 44 | insert: function(pos, text, callback) { 45 | if (pos == null) pos = 0; 46 | 47 | var op = []; 48 | var docPos = {index: 0, offset: 0}; 49 | var snapshot = getSnapshot(); 50 | 51 | // Skip to the specified position 52 | appendSkipChars(op, snapshot, docPos, pos); 53 | 54 | // Append the text 55 | append(op, {i: text}); 56 | appendSkipChars(op, snapshot, docPos); 57 | submitOp(op, callback); 58 | return op; 59 | }, 60 | 61 | // Remove length of text at pos 62 | remove: function(pos, len, callback) { 63 | var op = []; 64 | var docPos = {index: 0, offset: 0}; 65 | var snapshot = getSnapshot(); 66 | 67 | // Skip to the position 68 | appendSkipChars(op, snapshot, docPos, pos); 69 | 70 | while (len > 0) { 71 | var part = takeDoc(snapshot, docPos, len, true); 72 | 73 | // We only need to delete actual characters. This should also be valid if 74 | // we deleted all the tombstones in the document here. 75 | if (typeof part === 'string') { 76 | append(op, {d: part.length}); 77 | len -= part.length; 78 | } else { 79 | append(op, part); 80 | } 81 | } 82 | 83 | appendSkipChars(op, snapshot, docPos); 84 | submitOp(op, callback); 85 | return op; 86 | }, 87 | 88 | _beforeOp: function() { 89 | // Its a shame we need this. This also currently relies on snapshots being 90 | // cloned during apply(). This is used in _onOp below to figure out what 91 | // text was _actually_ inserted and removed. 92 | // 93 | // Maybe instead we should do all the _onOp logic here and store the result 94 | // then play the events when _onOp is actually called or something. 95 | this.__prevSnapshot = getSnapshot(); 96 | }, 97 | 98 | _onOp: function(op) { 99 | var textPos = 0; 100 | var docPos = {index:0, offset:0}; 101 | // The snapshot we get here is the document state _AFTER_ the specified op 102 | // has been applied. That means any deleted characters are now tombstones. 103 | var prevSnapshot = this.__prevSnapshot; 104 | 105 | for (var i = 0; i < op.length; i++) { 106 | var component = op[i]; 107 | var part, remainder; 108 | 109 | if (typeof component == 'number') { 110 | // Skip 111 | for (remainder = component; 112 | remainder > 0; 113 | remainder -= part.length || part) { 114 | 115 | part = takeDoc(prevSnapshot, docPos, remainder); 116 | if (typeof part === 'string') 117 | textPos += part.length; 118 | } 119 | } else if (component.i != null) { 120 | // Insert 121 | if (typeof component.i == 'string') { 122 | // ... and its an insert of text, not insert of tombstones 123 | if (this.onInsert) this.onInsert(textPos, component.i); 124 | textPos += component.i.length; 125 | } 126 | } else { 127 | // Delete 128 | for (remainder = component.d; 129 | remainder > 0; 130 | remainder -= part.length || part) { 131 | 132 | part = takeDoc(prevSnapshot, docPos, remainder); 133 | if (typeof part == 'string' && this.onRemove) 134 | this.onRemove(textPos, part.length); 135 | } 136 | } 137 | } 138 | } 139 | }; 140 | }; 141 | 142 | api.provides = {text: true}; 143 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var type = require('./text-tp2'); 2 | type.api = require('./api'); 3 | 4 | exports.type = type; 5 | -------------------------------------------------------------------------------- /lib/text-tp2.js: -------------------------------------------------------------------------------- 1 | // A TP2 implementation of text, following this spec: 2 | // http://code.google.com/p/lightwave/source/browse/trunk/experimental/ot/README 3 | // 4 | // A document is made up of a string and a set of tombstones inserted throughout 5 | // the string. For example, 'some ', (2 tombstones), 'string'. 6 | // 7 | // This is encoded in a document as ['some ', (2 tombstones), 'string'] 8 | // (It should be encoded as {s:'some string', t:[5, -2, 6]} because thats 9 | // faster in JS, but its not.) 10 | // 11 | // Ops are lists of components which iterate over the whole document. (I might 12 | // change this at some point, but a version thats less strict is backwards 13 | // compatible.) 14 | // 15 | // Components are either: 16 | // N: Skip N characters in the original document 17 | // {i:'str'}: Insert 'str' at the current position in the document 18 | // {i:N}: Insert N tombstones at the current position in the document 19 | // {d:N}: Delete (tombstone) N characters at the current position in the document 20 | // 21 | // Eg: [3, {i:'hi'}, 5, {d:8}] 22 | // 23 | // Snapshots are lists with characters and tombstones. Characters are stored in strings 24 | // and adjacent tombstones are flattened into numbers. 25 | // 26 | // Eg, the document: 'Hello .....world' ('.' denotes tombstoned (deleted) characters) 27 | // would be represented by a document snapshot of ['Hello ', 5, 'world'] 28 | 29 | var type = module.exports = { 30 | name: 'text-tp2', 31 | tp2: true, 32 | uri: 'http://sharejs.org/types/text-tp2v1', 33 | create: function(initial) { 34 | if (initial == null) { 35 | initial = ''; 36 | } else { 37 | if (typeof initial != 'string') throw new Error('Initial data must be a string'); 38 | } 39 | 40 | return { 41 | charLength: initial.length, 42 | totalLength: initial.length, 43 | data: initial.length ? [initial] : [] 44 | }; 45 | }, 46 | 47 | serialize: function(doc) { 48 | if (!doc.data) { 49 | throw new Error('invalid doc snapshot'); 50 | } 51 | return doc.data; 52 | }, 53 | 54 | deserialize: function(data) { 55 | var doc = type.create(); 56 | doc.data = data; 57 | 58 | for (var i = 0; i < data.length; i++) { 59 | var component = data[i]; 60 | 61 | if (typeof component === 'string') { 62 | doc.charLength += component.length; 63 | doc.totalLength += component.length; 64 | } else { 65 | doc.totalLength += component; 66 | } 67 | } 68 | 69 | return doc; 70 | } 71 | }; 72 | 73 | var isArray = Array.isArray || function(obj) { 74 | return Object.prototype.toString.call(obj) == '[object Array]'; 75 | }; 76 | 77 | var checkOp = function(op) { 78 | if (!isArray(op)) throw new Error('Op must be an array of components'); 79 | 80 | var last = null; 81 | for (var i = 0; i < op.length; i++) { 82 | var c = op[i]; 83 | if (typeof c == 'object') { 84 | // The component is an insert or a delete. 85 | if (c.i !== undefined) { // Insert. 86 | if (!((typeof c.i === 'string' && c.i.length > 0) // String inserts 87 | || (typeof c.i === 'number' && c.i > 0))) // Tombstone inserts 88 | throw new Error('Inserts must insert a string or a +ive number'); 89 | 90 | } else if (c.d !== undefined) { // Delete 91 | if (!(typeof c.d === 'number' && c.d > 0)) 92 | throw new Error('Deletes must be a +ive number'); 93 | 94 | } else throw new Error('Operation component must define .i or .d'); 95 | 96 | } else { 97 | // The component must be a skip. 98 | if (typeof c != 'number') throw new Error('Op components must be objects or numbers'); 99 | 100 | if (c <= 0) throw new Error('Skip components must be a positive number'); 101 | if (typeof last === 'number') throw new Error('Adjacent skip components should be combined'); 102 | } 103 | 104 | last = c; 105 | } 106 | }; 107 | 108 | // Take the next part from the specified position in a document snapshot. 109 | // position = {index, offset}. It will be updated. 110 | var takeDoc = type._takeDoc = function(doc, position, maxlength, tombsIndivisible) { 111 | if (position.index >= doc.data.length) 112 | throw new Error('Operation goes past the end of the document'); 113 | 114 | var part = doc.data[position.index]; 115 | 116 | // This can be written as an ugly-arsed giant ternary statement, but its much 117 | // more readable like this. Uglify will convert it into said ternary anyway. 118 | var result; 119 | if (typeof part == 'string') { 120 | if (maxlength != null) { 121 | result = part.slice(position.offset, position.offset + maxlength); 122 | } else { 123 | result = part.slice(position.offset); 124 | } 125 | } else { 126 | if (maxlength == null || tombsIndivisible) { 127 | result = part - position.offset; 128 | } else { 129 | result = Math.min(maxlength, part - position.offset); 130 | } 131 | } 132 | 133 | var resultLen = result.length || result; 134 | 135 | if ((part.length || part) - position.offset > resultLen) { 136 | position.offset += resultLen; 137 | } else { 138 | position.index++; 139 | position.offset = 0; 140 | } 141 | 142 | return result; 143 | }; 144 | 145 | // Append a part to the end of a document 146 | var appendDoc = type._appendDoc = function(doc, p) { 147 | if (p === 0 || p === '') return; 148 | 149 | if (typeof p === 'string') { 150 | doc.charLength += p.length; 151 | doc.totalLength += p.length; 152 | } else { 153 | doc.totalLength += p; 154 | } 155 | 156 | var data = doc.data; 157 | if (data.length === 0) { 158 | data.push(p); 159 | } else if (typeof data[data.length - 1] === typeof p) { 160 | data[data.length - 1] += p; 161 | } else { 162 | data.push(p); 163 | } 164 | }; 165 | 166 | // Apply the op to the document. The document is not modified in the process. 167 | type.apply = function(doc, op) { 168 | if (doc.totalLength == null || doc.charLength == null || !isArray(doc.data)) { 169 | throw new Error('Snapshot is invalid'); 170 | } 171 | checkOp(op); 172 | 173 | var newDoc = type.create(); 174 | var position = {index: 0, offset: 0}; 175 | 176 | for (var i = 0; i < op.length; i++) { 177 | var component = op[i]; 178 | var remainder, part; 179 | 180 | if (typeof component == 'number') { // Skip 181 | remainder = component; 182 | while (remainder > 0) { 183 | part = takeDoc(doc, position, remainder); 184 | appendDoc(newDoc, part); 185 | remainder -= part.length || part; 186 | } 187 | 188 | } else if (component.i !== undefined) { // Insert 189 | appendDoc(newDoc, component.i); 190 | 191 | } else if (component.d !== undefined) { // Delete 192 | remainder = component.d; 193 | while (remainder > 0) { 194 | part = takeDoc(doc, position, remainder); 195 | remainder -= part.length || part; 196 | } 197 | appendDoc(newDoc, component.d); 198 | } 199 | } 200 | return newDoc; 201 | }; 202 | 203 | // Append an op component to the end of the specified op. Exported for the 204 | // randomOpGenerator. 205 | var append = type._append = function(op, component) { 206 | var last; 207 | 208 | if (component === 0 || component.i === '' || component.i === 0 || component.d === 0) { 209 | // Drop the new component. 210 | } else if (op.length === 0) { 211 | op.push(component); 212 | } else { 213 | last = op[op.length - 1]; 214 | if (typeof component == 'number' && typeof last == 'number') { 215 | op[op.length - 1] += component; 216 | } else if (component.i != null && (last.i != null) && typeof last.i === typeof component.i) { 217 | last.i += component.i; 218 | } else if (component.d != null && (last.d != null)) { 219 | last.d += component.d; 220 | } else { 221 | op.push(component); 222 | } 223 | } 224 | }; 225 | 226 | var take = function(op, cursor, maxlength, insertsIndivisible) { 227 | if (cursor.index === op.length) return null; 228 | var e = op[cursor.index]; 229 | var current; 230 | var result; 231 | 232 | var offset = cursor.offset; 233 | 234 | // if the current element is a skip, an insert of a number or a delete 235 | if (typeof (current = e) == 'number' || typeof (current = e.i) == 'number' || (current = e.d) != null) { 236 | var c; 237 | if ((maxlength == null) || current - offset <= maxlength || (insertsIndivisible && e.i != null)) { 238 | // Return the rest of the current element. 239 | c = current - offset; 240 | ++cursor.index; 241 | cursor.offset = 0; 242 | } else { 243 | cursor.offset += maxlength; 244 | c = maxlength; 245 | } 246 | 247 | // Package the component back up. 248 | if (e.i != null) { 249 | return {i: c}; 250 | } else if (e.d != null) { 251 | return {d: c}; 252 | } else { 253 | return c; 254 | } 255 | } else { // Insert of a string. 256 | if ((maxlength == null) || e.i.length - offset <= maxlength || insertsIndivisible) { 257 | result = {i: e.i.slice(offset)}; 258 | ++cursor.index; 259 | cursor.offset = 0; 260 | } else { 261 | result = {i: e.i.slice(offset, offset + maxlength)}; 262 | cursor.offset += maxlength; 263 | } 264 | return result; 265 | } 266 | }; 267 | 268 | // Find and return the length of an op component 269 | var componentLength = function(component) { 270 | if (typeof component === 'number') { 271 | return component; 272 | } else if (typeof component.i === 'string') { 273 | return component.i.length; 274 | } else { 275 | return component.d || component.i; 276 | } 277 | }; 278 | 279 | // Normalize an op, removing all empty skips and empty inserts / deletes. 280 | // Concatenate adjacent inserts and deletes. 281 | type.normalize = function(op) { 282 | var newOp = []; 283 | for (var i = 0; i < op.length; i++) { 284 | append(newOp, op[i]); 285 | } 286 | return newOp; 287 | }; 288 | 289 | // This is a helper method to transform and prune. goForwards is true for transform, false for prune. 290 | var transformer = function(op, otherOp, goForwards, side) { 291 | checkOp(op); 292 | checkOp(otherOp); 293 | 294 | var newOp = []; 295 | 296 | // Cursor moving over op. Used by take 297 | var cursor = {index:0, offset:0}; 298 | 299 | for (var i = 0; i < otherOp.length; i++) { 300 | var component = otherOp[i]; 301 | var len = componentLength(component); 302 | var chunk; 303 | 304 | if (component.i != null) { // Insert text or tombs 305 | if (goForwards) { // Transform - insert skips over deleted parts. 306 | if (side === 'left') { 307 | // The left side insert should go first. 308 | var next; 309 | while ((next = op[cursor.index]) && next.i != null) { 310 | append(newOp, take(op, cursor)); 311 | } 312 | } 313 | // In any case, skip the inserted text. 314 | append(newOp, len); 315 | 316 | } else { // Prune. Remove skips for inserts. 317 | while (len > 0) { 318 | chunk = take(op, cursor, len, true); 319 | 320 | // The chunk will be null if we run out of components in the other op. 321 | if (chunk === null) throw new Error('The transformed op is invalid'); 322 | if (chunk.d != null) 323 | throw new Error('The transformed op deletes locally inserted characters - it cannot be purged of the insert.'); 324 | 325 | if (typeof chunk == 'number') 326 | len -= chunk; 327 | else 328 | append(newOp, chunk); 329 | } 330 | } 331 | } else { // Skips or deletes. 332 | while (len > 0) { 333 | chunk = take(op, cursor, len, true); 334 | if (chunk === null) throw new Error('The op traverses more elements than the document has'); 335 | 336 | append(newOp, chunk); 337 | if (!chunk.i) len -= componentLength(chunk); 338 | } 339 | } 340 | } 341 | 342 | // Append extras from op1. 343 | var component; 344 | while ((component = take(op, cursor))) { 345 | if (component.i === undefined) { 346 | throw new Error("Remaining fragments in the op: " + component); 347 | } 348 | append(newOp, component); 349 | } 350 | return newOp; 351 | }; 352 | 353 | // transform op1 by op2. Return transformed version of op1. op1 and op2 are 354 | // unchanged by transform. Side should be 'left' or 'right', depending on if 355 | // op1.id <> op2.id. 356 | // 357 | // 'left' == client op for ShareJS. 358 | type.transform = function(op, otherOp, side) { 359 | if (side != 'left' && side != 'right') 360 | throw new Error("side (" + side + ") should be 'left' or 'right'"); 361 | 362 | return transformer(op, otherOp, true, side); 363 | }; 364 | 365 | type.prune = function(op, otherOp) { 366 | return transformer(op, otherOp, false); 367 | }; 368 | 369 | type.compose = function(op1, op2) { 370 | //var chunk, chunkLength, component, length, result, take, _, _i, _len, _ref; 371 | if (op1 == null) return op2; 372 | 373 | checkOp(op1); 374 | checkOp(op2); 375 | 376 | var result = []; 377 | 378 | // Cursor over op1. 379 | var cursor = {index:0, offset:0}; 380 | 381 | var component; 382 | 383 | for (var i = 0; i < op2.length; i++) { 384 | component = op2[i]; 385 | var len, chunk; 386 | 387 | if (typeof component === 'number') { // Skip 388 | // Just copy from op1. 389 | len = component; 390 | while (len > 0) { 391 | chunk = take(op1, cursor, len); 392 | if (chunk === null) 393 | throw new Error('The op traverses more elements than the document has'); 394 | 395 | append(result, chunk); 396 | len -= componentLength(chunk); 397 | } 398 | 399 | } else if (component.i !== undefined) { // Insert 400 | append(result, {i: component.i}); 401 | 402 | } else { // Delete 403 | len = component.d; 404 | while (len > 0) { 405 | chunk = take(op1, cursor, len); 406 | if (chunk === null) 407 | throw new Error('The op traverses more elements than the document has'); 408 | 409 | var chunkLength = componentLength(chunk); 410 | 411 | if (chunk.i !== undefined) 412 | append(result, {i: chunkLength}); 413 | else 414 | append(result, {d: chunkLength}); 415 | 416 | len -= chunkLength; 417 | } 418 | } 419 | } 420 | 421 | // Append extras from op1. 422 | while ((component = take(op1, cursor))) { 423 | if (component.i === undefined) { 424 | throw new Error("Remaining fragments in op1: " + component); 425 | } 426 | append(result, component); 427 | } 428 | return result; 429 | }; 430 | 431 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ot-text-tp2", 3 | "version": "1.0.0", 4 | "description": "OT type for text, with transform property 2 (suitable for P2P)", 5 | "main": "lib/index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "mocha test" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/ottypes/text-tp2.git" 15 | }, 16 | "keywords": [ 17 | "ot", 18 | "text", 19 | "sharejs" 20 | ], 21 | "author": "Joseph Gentle ", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/ottypes/text-tp2/issues" 25 | }, 26 | "homepage": "https://github.com/ottypes/text-tp2", 27 | "devDependencies": { 28 | "ot-fuzzer": "^1.0.0", 29 | "mocha": "^1.20.1", 30 | "coffee-script": "^1.7.1", 31 | "ot-text": "^1.0.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/genOp.coffee: -------------------------------------------------------------------------------- 1 | text = require('../lib').type 2 | {randomInt, randomReal, randomWord} = require 'ot-fuzzer' 3 | 4 | module.exports = generateRandomOp = (doc) -> 5 | position = {index:0, offset:0} 6 | 7 | remainder = doc.totalLength 8 | 9 | newDoc = text.create() 10 | 11 | op = [] 12 | 13 | {_appendDoc:appendDoc, _takeDoc:takeDoc, _append:append} = text 14 | 15 | addSkip = (length = Math.min(remainder, randomInt(doc.totalLength / 2) + 1)) -> 16 | remainder -= length 17 | 18 | append op, length 19 | while length > 0 20 | part = takeDoc doc, position, length 21 | appendDoc newDoc, part 22 | length -= part.length || part 23 | 24 | addInsert = -> 25 | # Insert a random word from the list 26 | content = if randomInt(2) then randomWord() + ' ' else randomInt(5) + 1 27 | append op, {i:content} 28 | appendDoc newDoc, content 29 | 30 | addDelete = -> 31 | length = Math.min(remainder, randomInt(doc.totalLength / 2) + 1) 32 | remainder -= length 33 | 34 | appendDoc newDoc, length 35 | append op, {d:length} 36 | 37 | while length > 0 38 | part = takeDoc doc, position, length 39 | length -= part.length || part 40 | 41 | r = 0.9 42 | while remainder > 0 and randomReal() < r 43 | addSkip() if randomReal() < 0.8 44 | 45 | r *= 0.8 46 | 47 | if randomReal() < 0.9 48 | if randomReal() < 0.3 then addInsert() else addDelete() 49 | 50 | addSkip(remainder) if remainder > 0 51 | 52 | # The code above will never insert at the end of the document. Thats important... 53 | addInsert() if randomReal() < 0.3 54 | 55 | [op, newDoc] 56 | 57 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers coffee:coffee-script/register 2 | --reporter spec 3 | --check-leaks 4 | -------------------------------------------------------------------------------- /test/text-tp2.coffee: -------------------------------------------------------------------------------- 1 | # Tests for the TP2 capable text implementation 2 | 3 | fs = require 'fs' 4 | assert = require 'assert' 5 | 6 | fuzzer = require 'ot-fuzzer' 7 | 8 | text = require('../lib').type 9 | genOp = require './genOp' 10 | 11 | describe 'text-tp2', -> 12 | it 'creates using provided string data', -> 13 | assert.deepEqual ['hi'], text.serialize text.create 'hi' 14 | assert.deepEqual [], text.serialize text.create '' 15 | assert.deepEqual [], text.serialize text.create() 16 | 17 | it 'transforms sanely', -> 18 | tc = (op1, op2, expected, delta) -> 19 | if delta? 20 | assert.deepEqual (text.transform op1, op2, delta), expected 21 | assert.deepEqual (text.prune expected, op2, delta), op1 22 | else 23 | assert.deepEqual (text.transform op1, op2, 'left'), expected 24 | assert.deepEqual (text.prune expected, op2, 'left'), op1 25 | 26 | assert.deepEqual (text.transform op1, op2, 'right'), expected 27 | assert.deepEqual (text.prune expected, op2, 'right'), op1 28 | 29 | tc [], [], [] 30 | tc [10], [10], [10] 31 | tc [{i:'hi'}], [], [{i:'hi'}] 32 | tc [{i:5}], [], [{i:5}] 33 | tc [{d:5}], [5], [{d:5}] 34 | 35 | tc [10], [10, {i:'hi'}], [12] 36 | tc [{i:'aaa'}, 10], [{i:'bbb'}, 10], [{i:'aaa'}, 13], 'left' 37 | tc [{i:'aaa'}, 10], [{i:'bbb'}, 10], [3, {i:'aaa'}, 10], 'right' 38 | tc [10, {i:5}], [{i:'hi'}, 10], [12, {i:5}] 39 | tc [{d:5}], [{i:'hi'}, 5], [2, {d:5}] 40 | 41 | tc [10], [{d:10}], [10] 42 | tc [{i:'hi'}, 10], [{d:10}], [{i:'hi'}, 10] 43 | tc [10, {i:5}], [{d:10}], [10, {i:5}] 44 | tc [{d:5}], [{d:5}], [{d:5}] 45 | 46 | tc [{i:'mimsy'}], [{i: 10}], [{i:'mimsy'}, 10], 'left' 47 | 48 | it 'normalizes', -> 49 | tn = (input, expected) -> 50 | assert.deepEqual text.normalize(input), expected 51 | 52 | tn [0], [] 53 | tn [{i:''}], [] 54 | tn [{d:0}], [] 55 | tn [{i:0}], [] 56 | 57 | tn [1, 1], [2] 58 | tn [2, 0], [2] 59 | 60 | tn [{i:4}, {i:5}], [{i:9}] 61 | tn [{d:4}, {d:5}], [{d:9}] 62 | tn [{i:4}, {d:5}], [{i:4}, {d:5}] 63 | 64 | tn [{i:'a'}, 0], [{i:'a'}] 65 | tn [{i:'a'}, {i:'b'}], [{i:'ab'}] 66 | tn [0, {i:'a'}, 0, {i:'b'}, 0], [{i:'ab'}] 67 | 68 | tn [{i:'ab'}, {i:''}], [{i:'ab'}] 69 | tn [{i:'ab'}, {d:0}], [{i:'ab'}] 70 | tn [{i:'ab'}, {i:0}], [{i:'ab'}] 71 | 72 | tn [{i:'a'}, 1, {i:'b'}], [{i:'a'}, 1, {i:'b'}] 73 | 74 | checkLengths = (doc) -> 75 | totalLength = doc.data.reduce ((x, y) -> x + (y.length || y)), 0 76 | charLength = doc.data.reduce ((x, y) -> x + (y.length || 0)), 0 77 | assert.strictEqual doc.charLength, charLength 78 | assert.strictEqual doc.totalLength, totalLength 79 | 80 | it 'deserializes', -> 81 | td = (data) -> 82 | doc = text.deserialize data 83 | assert.deepEqual doc.data, data 84 | checkLengths doc 85 | 86 | td [] 87 | td ['hi'] 88 | td [100] 89 | td [100, 'hi', 50, 'there'] 90 | td [100, 'hi', 50, 'there', 30] 91 | 92 | it 'applies', -> 93 | ta = (data, op, expected) -> 94 | doc = text.deserialize data 95 | newDoc = text.apply doc, op 96 | 97 | assert.deepEqual newDoc.data, expected 98 | checkLengths newDoc 99 | 100 | ta [''], [{i: 5}], [5] 101 | ta ['abc', 1, 'defghij'], [{d:5}, 6], [5, 'efghij'] 102 | ta [5, 'hi there', 5], [3, {d:4}, 11], [7, ' there', 5] 103 | 104 | it 'composes', -> 105 | tc = (op1, op2, expected) -> 106 | c = text.compose op1, op2 107 | assert.deepEqual c, expected 108 | 109 | tc [{i:'abcde'}], [3, {d:1}, 1], [{i:'abc'}, {i:1}, {i:'e'}] 110 | 111 | describe 'randomizer', -> it 'passes', -> 112 | @slow 2000 113 | fuzzer text, genOp 114 | 115 | # Its sort of a hack to use this directly here 116 | require('ot-text/test/api') text, genOp 117 | --------------------------------------------------------------------------------