├── .gitignore ├── .travis.yml ├── README.md ├── lib ├── api.js ├── index.js └── text.js ├── package.json ├── scripts └── genTestData.js └── test ├── api.coffee ├── genOp.coffee ├── mocha.opts ├── text-transform-tests.json └── text.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.DS_Store 3 | node_modules 4 | apply.json 5 | transform.json 6 | compose.json 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "node" 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Plaintext OT Type 2 | 3 | **NOTE**: This OT type counts characters using UTF16 offsets instead of 4 | unicode codepoints. This is slightly faster in javascript, but its 5 | incompatible with ot implementations in other languages. For future 6 | projects I recommend that you use 7 | [ot-text-unicode](https://github.com/ottypes/text-unicode) instead. 8 | ot-text-unicode also has full typescript type information and it supports ropes 9 | for better performance string editing. 10 | 11 | 12 | This OT type can be used to edit plaintext documents, like sourcecode or 13 | markdown. 14 | 15 | This project's [history is here](https://github.com/share/ShareJS/blob/0.6/src/types/text2.coffee). 16 | 17 | For documentation on the spec this type implements, see [ottypes/docs](https://github.com/ottypes/docs). 18 | 19 | ## Spec 20 | 21 | The plaintext OT type thinks of the document as a giant string, and edits index 22 | into the string directly. This is different from most text editors, which break 23 | up a document into an array of lines. For small documents on modern computers, 24 | the conversion isn't particularly expensive. However, if you have giant 25 | documents you should be using a rope library like 26 | [jumprope](https://github.com/josephg/jumprope) or 27 | [librope](https://github.com/josephg/librope). 28 | 29 | Each operation describes a traversal over the document. The traversal can edit 30 | the document as it goes. 31 | 32 | For example, given the document: 33 | 34 | ``` 35 | "ABCDEFG" 36 | ``` 37 | 38 | You could apply the operation 39 | 40 | ``` 41 | [1, ' hi ', 2, {d:3}] 42 | ``` 43 | 44 | This operation will skip the first character, insert ' hi ', skip 2 more 45 | characters then delete the next 3 characters. The result would be: 46 | 47 | ``` 48 | "A hi BCG" 49 | ``` 50 | 51 | ### Operations 52 | 53 | Operations are lists of components, which move along the document. Each 54 | component is one of 55 | 56 | - **Number N**: Skip forward *N* characters in the document 57 | - **"str"**: Insert *"str"* at the current position in the document 58 | - **{d:N}**: Delete *N* characters at the current position in the document 59 | 60 | The operation does not have to skip the last characters in the document. 61 | 62 | ### Selections 63 | 64 | The text type also has methods for manipulating selections. 65 | 66 | Selection ranges are either a single number (the cursor position) or a pair of 67 | [anchor, focus] numbers (aka [start, end]) of the selection range. Be aware 68 | that end can be before start. 69 | 70 | --- 71 | 72 | # Commentary 73 | 74 | This is the 3rd iteration of ShareJS's plaintext type. It hasn't been changed 75 | in a long time now. 76 | 77 | The first iteration was similar, except it is invertable. Invertability is 78 | nice, but I want to eventually build an arbitrary P2P OT system, and in a p2p 79 | setting invertibillity becomes impractical to achieve. I don't want systems to 80 | depend on it. 81 | 82 | The second iteration made each component specify a location and an edit there. 83 | Operations were lists of these edits. Because the components were not sorted, 84 | if you transform two big operations by one another it requires M\*N 85 | time to transform. The components could be sorted to fix this, but if you're 86 | going to do that you may as well just make them sorted by design - which is 87 | what the current text implementation does. I thought the individual edits style 88 | was better because I expected it to be simpler, but when I implemented it I 89 | found the implementation of each method was almost identical in size. 90 | 91 | There is also a [C implementation of this 92 | type](https://github.com/share/libot/blob/master/text.h) which is [insanely](h 93 | ttps://dl.dropboxusercontent.com/u/2494815/ot%20apply%20bench%201.png) [fast]( 94 | https://dl.dropboxusercontent.com/u/2494815/ot%20apply%20bench%202.png). The 95 | implementations are almost the same, except this javascript implemention 96 | counts characters using 16 bit words and the C implementation counts 97 | characters using unicode codepoints. If you have any characters in the astral 98 | plane in your document (like emoji 😅), edit & cursor positions will be 99 | misaligned between implementations. See [here for more 100 | information](http://josephg.com/blog/string-length-lies). If you are building 101 | a cross-platform application, use the newer [ot-text- 102 | unicode](https://github.com/ottypes/text-unicode) instead. 103 | 104 | --- 105 | 106 | # License 107 | 108 | All code contributed to this repository is licensed under the standard MIT license: 109 | 110 | Copyright 2011 ottypes library contributors 111 | 112 | Permission is hereby granted, free of charge, to any person obtaining a copy 113 | of this software and associated documentation files (the "Software"), to deal 114 | in the Software without restriction, including without limitation the rights 115 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 116 | copies of the Software, and to permit persons to whom the Software is 117 | furnished to do so, subject to the following condition: 118 | 119 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 120 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 121 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 122 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 123 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 124 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 125 | THE SOFTWARE. 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | // Text document API for the 'text' type. This implements some standard API 2 | // methods for any text-like type, so you can easily bind a textarea or 3 | // something without being fussy about the underlying OT implementation. 4 | // 5 | // The API is desigend as a set of functions to be mixed in to some context 6 | // object as part of its lifecycle. It expects that object to have getSnapshot 7 | // and submitOp methods, and call _onOp when an operation is received. 8 | // 9 | // This API defines: 10 | // 11 | // - getLength() returns the length of the document in characters 12 | // - getText() returns a string of the document 13 | // - insert(pos, text, [callback]) inserts text at position pos in the document 14 | // - remove(pos, length, [callback]) removes length characters at position pos 15 | // 16 | // A user can define: 17 | // - onInsert(pos, text): Called when text is inserted. 18 | // - onRemove(pos, length): Called when text is removed. 19 | 20 | module.exports = api; 21 | function api(getSnapshot, submitOp) { 22 | return { 23 | // Returns the text content of the document 24 | get: getSnapshot, 25 | 26 | // Returns the number of characters in the string 27 | getLength() { return getSnapshot().length }, 28 | 29 | // Insert the specified text at the given position in the document 30 | insert(pos, text, callback) { 31 | return submitOp([pos, text], callback) 32 | }, 33 | 34 | remove(pos, length, callback) { 35 | return submitOp([pos, {d:length}], callback) 36 | }, 37 | 38 | // When you use this API, you should implement these two methods 39 | // in your editing context. 40 | //onInsert: function(pos, text) {}, 41 | //onRemove: function(pos, removedLength) {}, 42 | 43 | _onOp(op) { 44 | var pos = 0 45 | var spos = 0 46 | for (var i = 0; i < op.length; i++) { 47 | var component = op[i] 48 | switch (typeof component) { 49 | case 'number': 50 | pos += component 51 | spos += component 52 | break 53 | case 'string': 54 | if (this.onInsert) this.onInsert(pos, component) 55 | pos += component.length 56 | break 57 | case 'object': 58 | if (this.onRemove) this.onRemove(pos, component.d) 59 | spos += component.d 60 | } 61 | } 62 | } 63 | } 64 | } 65 | api.provides = {text: true} 66 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var type = require('./text'); 2 | type.api = require('./api'); 3 | 4 | module.exports = { 5 | type: type 6 | }; 7 | -------------------------------------------------------------------------------- /lib/text.js: -------------------------------------------------------------------------------- 1 | /* Text OT! 2 | * 3 | * This is an OT implementation for text. It is the standard implementation of 4 | * text used by ShareJS. 5 | * 6 | * This type is composable but non-invertable. Its similar to ShareJS's old 7 | * text-composable type, but its not invertable and its very similar to the 8 | * text-tp2 implementation but it doesn't support tombstones or purging. 9 | * 10 | * Ops are lists of components which iterate over the document. 11 | * Components are either: 12 | * A number N: Skip N characters in the original document 13 | * "str" : Insert "str" at the current position in the document 14 | * {d:N} : Delete N characters at the current position in the document 15 | * 16 | * Eg: [3, 'hi', 5, {d:8}] 17 | * 18 | * The operation does not have to skip the last characters in the document. 19 | * 20 | * Snapshots are strings. 21 | * 22 | * Cursors are either a single number (which is the cursor position) or a pair of 23 | * [anchor, focus] (aka [start, end]). Be aware that end can be before start. 24 | */ 25 | 26 | /** @module text */ 27 | 28 | exports.name = 'text' 29 | exports.uri = 'http://sharejs.org/types/textv1' 30 | 31 | /** Create a new text snapshot. 32 | * 33 | * @param {string} initial - initial snapshot data. Optional. Defaults to ''. 34 | */ 35 | exports.create = (initial) => { 36 | if ((initial != null) && typeof initial !== 'string') { 37 | throw Error('Initial data must be a string') 38 | } 39 | return initial || '' 40 | } 41 | 42 | /** Check the operation is valid. Throws if not valid. */ 43 | const checkOp = function(op) { 44 | if (!Array.isArray(op)) throw Error('Op must be an array of components'); 45 | 46 | let last = null 47 | for (let i = 0; i < op.length; i++) { 48 | const c = op[i] 49 | switch (typeof c) { 50 | case 'object': 51 | // The only valid objects are {d:X} for +ive values of X. 52 | if (!(typeof c.d === 'number' && c.d > 0)) throw Error('Object components must be deletes of size > 0') 53 | break 54 | case 'string': 55 | // Strings are inserts. 56 | if (!(c.length > 0)) throw Error('Inserts cannot be empty') 57 | break 58 | case 'number': 59 | // Numbers must be skips. They have to be +ive numbers. 60 | if (!(c > 0)) throw Error('Skip components must be >0') 61 | if (typeof last === 'number') throw Error('Adjacent skip components should be combined') 62 | break 63 | } 64 | last = c 65 | } 66 | 67 | if (typeof last === 'number') throw Error('Op has a trailing skip') 68 | } 69 | 70 | /** Check that the given selection range is valid. */ 71 | const checkSelection = selection => { 72 | // This may throw from simply inspecting selection[0] / selection[1]. Thats 73 | // sort of ok, though it'll generate the wrong message. 74 | if (typeof selection !== 'number' 75 | && (typeof selection[0] !== 'number' || typeof selection[1] !== 'number')) { 76 | throw Error('Invalid selection') 77 | } 78 | } 79 | 80 | /** Make a function that appends to the given operation. */ 81 | const makeAppend = op => component => { 82 | if (!component || component.d === 0) { 83 | // The component is a no-op. Ignore! 84 | 85 | } else if (op.length === 0) { 86 | op.push(component) 87 | 88 | } else if (typeof component === typeof op[op.length - 1]) { 89 | if (typeof component === 'object') { 90 | op[op.length - 1].d += component.d 91 | } else { 92 | op[op.length - 1] += component 93 | } 94 | } else { 95 | op.push(component) 96 | } 97 | } 98 | 99 | /** Makes and returns utility functions take and peek. */ 100 | const makeTake = function(op) { 101 | // The index of the next component to take 102 | let idx = 0 103 | // The offset into the component 104 | let offset = 0 105 | 106 | // Take up to length n from the front of op. If n is -1, take the entire next 107 | // op component. If indivisableField == 'd', delete components won't be separated. 108 | // If indivisableField == 'i', insert components won't be separated. 109 | const take = (n, indivisableField) => { 110 | // We're at the end of the operation. The op has skips, forever. Infinity 111 | // might make more sense than null here. 112 | if (idx === op.length) 113 | return n === -1 ? null : n 114 | 115 | const c = op[idx] 116 | let part 117 | if (typeof c === 'number') { 118 | // Skip 119 | if (n === -1 || c - offset <= n) { 120 | part = c - offset 121 | ++idx 122 | offset = 0 123 | return part 124 | } else { 125 | offset += n 126 | return n 127 | } 128 | } else if (typeof c === 'string') { 129 | // Insert 130 | if (n === -1 || indivisableField === 'i' || c.length - offset <= n) { 131 | part = c.slice(offset) 132 | ++idx 133 | offset = 0 134 | return part 135 | } else { 136 | part = c.slice(offset, offset + n) 137 | offset += n 138 | return part 139 | } 140 | } else { 141 | // Delete 142 | if (n === -1 || indivisableField === 'd' || c.d - offset <= n) { 143 | part = {d: c.d - offset} 144 | ++idx 145 | offset = 0 146 | return part 147 | } else { 148 | offset += n 149 | return {d: n} 150 | } 151 | } 152 | } 153 | 154 | // Peek at the next op that will be returned. 155 | const peekType = () => op[idx] 156 | 157 | return [take, peekType] 158 | } 159 | 160 | /** Get the length of a component */ 161 | const componentLength = c => typeof c === 'number' ? c : (c.length || c.d) 162 | 163 | /** Trim any excess skips from the end of an operation. 164 | * 165 | * There should only be at most one, because the operation was made with append. 166 | */ 167 | const trim = op => { 168 | if (op.length > 0 && typeof op[op.length - 1] === 'number') { 169 | op.pop() 170 | } 171 | return op 172 | } 173 | 174 | exports.normalize = function(op) { 175 | const newOp = [] 176 | const append = makeAppend(newOp) 177 | for (let i = 0; i < op.length; i++) append(op[i]) 178 | return trim(newOp) 179 | } 180 | 181 | /** Apply an operation to a document snapshot */ 182 | exports.apply = function(str, op) { 183 | if (typeof str !== 'string') { 184 | throw Error('Snapshot should be a string') 185 | } 186 | checkOp(op) 187 | 188 | // We'll gather the new document here and join at the end. 189 | const newDoc = [] 190 | 191 | for (let i = 0; i < op.length; i++) { 192 | const component = op[i] 193 | switch (typeof component) { 194 | case 'number': 195 | if (component > str.length) throw Error('The op is too long for this document') 196 | 197 | newDoc.push(str.slice(0, component)) 198 | // This might be slow for big strings. Consider storing the offset in 199 | // str instead of rewriting it each time. 200 | str = str.slice(component) 201 | break 202 | case 'string': 203 | newDoc.push(component) 204 | break 205 | case 'object': 206 | str = str.slice(component.d) 207 | break 208 | } 209 | } 210 | 211 | return newDoc.join('') + str 212 | } 213 | 214 | /** Transform op by otherOp. 215 | * 216 | * @param op - The operation to transform 217 | * @param otherOp - Operation to transform it by 218 | * @param side - Either 'left' or 'right' 219 | */ 220 | exports.transform = function(op, otherOp, side) { 221 | if (side !== 'left' && side !== 'right') { 222 | throw Error("side (" + side + ") must be 'left' or 'right'") 223 | } 224 | 225 | checkOp(op) 226 | checkOp(otherOp) 227 | 228 | const newOp = [] 229 | 230 | const append = makeAppend(newOp) 231 | const [take, peek] = makeTake(op) 232 | 233 | for (let i = 0; i < otherOp.length; i++) { 234 | const component = otherOp[i] 235 | 236 | let length, chunk 237 | switch (typeof component) { 238 | case 'number': // Skip 239 | length = component 240 | while (length > 0) { 241 | chunk = take(length, 'i') 242 | append(chunk) 243 | if (typeof chunk !== 'string') { 244 | length -= componentLength(chunk) 245 | } 246 | } 247 | break 248 | 249 | case 'string': // Insert 250 | if (side === 'left') { 251 | // The left insert should go first. 252 | if (typeof peek() === 'string') { 253 | append(take(-1)) 254 | } 255 | } 256 | 257 | // Otherwise skip the inserted text. 258 | append(component.length) 259 | break 260 | 261 | case 'object': // Delete 262 | length = component.d 263 | while (length > 0) { 264 | chunk = take(length, 'i') 265 | switch (typeof chunk) { 266 | case 'number': 267 | length -= chunk 268 | break 269 | case 'string': 270 | append(chunk) 271 | break 272 | case 'object': 273 | // The delete is unnecessary now - the text has already been deleted. 274 | length -= chunk.d 275 | } 276 | } 277 | break 278 | } 279 | } 280 | 281 | // Append any extra data in op1. 282 | let c 283 | while ((c = take(-1))) append(c) 284 | 285 | return trim(newOp) 286 | } 287 | 288 | /** Compose op1 and op2 together and return the result */ 289 | exports.compose = function(op1, op2) { 290 | checkOp(op1) 291 | checkOp(op2) 292 | 293 | const result = [] 294 | const append = makeAppend(result) 295 | const take = makeTake(op1)[0] 296 | 297 | for (let i = 0; i < op2.length; i++) { 298 | const component = op2[i] 299 | let length, chunk 300 | switch (typeof component) { 301 | case 'number': // Skip 302 | length = component 303 | while (length > 0) { 304 | chunk = take(length, 'd') 305 | append(chunk) 306 | if (typeof chunk !== 'object') { 307 | length -= componentLength(chunk) 308 | } 309 | } 310 | break 311 | 312 | case 'string': // Insert 313 | append(component) 314 | break 315 | 316 | case 'object': // Delete 317 | length = component.d 318 | 319 | while (length > 0) { 320 | chunk = take(length, 'd') 321 | 322 | switch (typeof chunk) { 323 | case 'number': 324 | append({d: chunk}) 325 | length -= chunk 326 | break 327 | case 'string': 328 | length -= chunk.length 329 | break 330 | case 'object': 331 | append(chunk) 332 | } 333 | } 334 | break 335 | } 336 | } 337 | 338 | let c 339 | while ((c = take(-1))) append(c) 340 | 341 | return trim(result) 342 | } 343 | 344 | 345 | const transformPosition = (cursor, op) => { 346 | let pos = 0 347 | for (let i = 0; i < op.length; i++) { 348 | const c = op[i] 349 | if (cursor <= pos) break 350 | 351 | // I could actually use the op_iter stuff above - but I think its simpler 352 | // like this. 353 | switch (typeof c) { 354 | case 'number': 355 | if (cursor <= pos + c) return cursor 356 | pos += c 357 | break 358 | 359 | case 'string': 360 | pos += c.length 361 | cursor += c.length 362 | break 363 | 364 | case 'object': 365 | cursor -= Math.min(c.d, cursor - pos) 366 | break 367 | } 368 | } 369 | return cursor 370 | } 371 | 372 | exports.transformSelection = function(selection, op, isOwnOp) { 373 | let pos = 0 374 | if (isOwnOp) { 375 | // Just track the position. We'll teleport the cursor to the end anyway. 376 | // This works because text ops don't have any trailing skips at the end - so the last 377 | // component is the last thing. 378 | for (let i = 0; i < op.length; i++) { 379 | const c = op[i] 380 | switch (typeof c) { 381 | case 'number': 382 | pos += c 383 | break 384 | case 'string': 385 | pos += c.length 386 | break 387 | // Just eat deletes. 388 | } 389 | } 390 | return pos 391 | } else { 392 | return typeof selection === 'number' 393 | ? transformPosition(selection, op) 394 | : [transformPosition(selection[0], op), transformPosition(selection[1], op)] 395 | } 396 | } 397 | 398 | exports.selectionEq = function(c1, c2) { 399 | if (c1[0] != null && c1[0] === c1[1]) c1 = c1[0] 400 | if (c2[0] != null && c2[0] === c2[1]) c2 = c2[0] 401 | return c1 === c2 || (c1[0] != null && c2[0] != null && c1[0] === c2[0] && c1[1] == c2[1]) 402 | } 403 | 404 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ot-text", 3 | "version": "1.0.2", 4 | "description": "OT type for plaintext", 5 | "main": "lib/index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "dependencies": {}, 10 | "devDependencies": { 11 | "coffeescript": "^2.3.2", 12 | "mocha": "5", 13 | "ot-fuzzer": "~1.0.0" 14 | }, 15 | "scripts": { 16 | "test": "mocha test/*.coffee" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/ottypes/text.git" 21 | }, 22 | "keywords": [ 23 | "ot", 24 | "text", 25 | "sharejs" 26 | ], 27 | "author": "Joseph Gentle ", 28 | "license": "ISC", 29 | "bugs": { 30 | "url": "https://github.com/ottypes/text/issues" 31 | }, 32 | "homepage": "https://github.com/ottypes/text" 33 | } 34 | -------------------------------------------------------------------------------- /scripts/genTestData.js: -------------------------------------------------------------------------------- 1 | // This little utility code generates apply.json and transform.json for use 2 | // testing text OT implementations in other languages without needing to port 3 | // the test suite. 4 | 5 | require('coffeescript/register') 6 | 7 | const fs = require('fs') 8 | const assert = require('assert') 9 | 10 | const fuzzer = require('ot-fuzzer') 11 | const type = require('../lib').type 12 | const genOp = require('../test/genOp') 13 | 14 | if (require.main === module) { 15 | const t = Object.assign({}, type) 16 | 17 | console.log('Generating apply.json, transform.json and compose.json') 18 | const af = fs.createWriteStream('apply.json') 19 | const tf = fs.createWriteStream('transform.json') 20 | const cf = fs.createWriteStream('compose.json') 21 | 22 | t.apply = (str, op) => { 23 | const result = type.apply(str, op) 24 | af.write(`${JSON.stringify({str, op, result})}\n`) 25 | return result 26 | } 27 | 28 | t.transform = (op, otherOp, side) => { 29 | const result = type.transform(op, otherOp, side) 30 | tf.write(`${JSON.stringify({op, otherOp, side, result})}\n`) 31 | return result 32 | } 33 | 34 | t.compose = (op1, op2) => { 35 | const result = type.compose(op1, op2) 36 | cf.write(`${JSON.stringify({op1, op2, result})}\n`) 37 | return result 38 | } 39 | 40 | fuzzer(t, genOp, 200) 41 | 42 | af.close() 43 | tf.close() 44 | } 45 | -------------------------------------------------------------------------------- /test/api.coffee: -------------------------------------------------------------------------------- 1 | # Tests for the text types using the DSL interface. This includes the standard 2 | # text type as well as text-tp2 (and any other text types we add). Rich text 3 | # should probably support this API too. 4 | assert = require 'assert' 5 | {randomInt, randomReal, randomWord} = require 'ot-fuzzer' 6 | 7 | module.exports = (type, genOp) -> describe "text api for '#{type.name}'", -> 8 | throw 'Type does not claim to provide the text api' unless type.api.provides.text 9 | beforeEach -> 10 | # This is a little copy of the context structure created in client/doc. 11 | # It would probably be better to copy the code, but whatever. 12 | 13 | @snapshot = type.create() 14 | getSnapshot = => @snapshot 15 | submitOp = (op, callback) => 16 | op = type.normalize op 17 | @snapshot = type.apply @snapshot, op 18 | callback?() 19 | 20 | @ctx = type.api getSnapshot, submitOp 21 | 22 | @apply = (op) -> 23 | @ctx._beforeOp? op 24 | submitOp op 25 | @ctx._onOp op 26 | 27 | it 'has no length when empty', -> 28 | assert.strictEqual @ctx.get(), '' 29 | assert.strictEqual @ctx.getLength(), 0 30 | 31 | it 'works with simple inserts and removes', -> 32 | @ctx.insert 0, 'hi' 33 | assert.strictEqual @ctx.get(), 'hi' 34 | assert.strictEqual @ctx.getLength(), 2 35 | 36 | @ctx.insert 2, ' mum' 37 | assert.strictEqual @ctx.get(), 'hi mum' 38 | assert.strictEqual @ctx.getLength(), 6 39 | 40 | @ctx.remove 0, 3 41 | assert.strictEqual @ctx.get(), 'mum' 42 | assert.strictEqual @ctx.getLength(), 3 43 | 44 | it 'gets edited correctly', -> 45 | # This is slow with text-tp2 because the snapshot gets filled with crap and 46 | # basically cloned with every operation in apply(). It could be fixed at 47 | # some point by making the document snapshot mutable (and make apply() not 48 | # clone the snapshot). 49 | # 50 | # If you do this, you'll also have to fix text-tp2.api._onOp. It currently 51 | # relies on being able to iterate through the previous document snapshot to 52 | # figure out what was inserted & removed. 53 | content = '' 54 | 55 | for i in [1..1000] 56 | if content.length == 0 || randomReal() > 0.5 57 | # Insert 58 | pos = randomInt(content.length + 1) 59 | str = randomWord() + ' ' 60 | @ctx.insert pos, str 61 | content = content[...pos] + str + content[pos..] 62 | else 63 | # Delete 64 | pos = randomInt content.length 65 | len = Math.min(randomInt(4), content.length - pos) 66 | @ctx.remove pos, len 67 | content = content[...pos] + content[(pos + len)..] 68 | 69 | assert.strictEqual @ctx.get(), content 70 | assert.strictEqual @ctx.getLength(), content.length 71 | 72 | it 'emits events correctly', -> 73 | contents = '' 74 | 75 | @ctx.onInsert = (pos, text) -> 76 | contents = contents[...pos] + text + contents[pos...] 77 | @ctx.onRemove = (pos, len) -> 78 | contents = contents[...pos] + contents[(pos + len)...] 79 | 80 | for i in [1..1000] 81 | [op, newDoc] = genOp @snapshot 82 | 83 | @apply op 84 | assert.strictEqual @ctx.get(), contents 85 | 86 | -------------------------------------------------------------------------------- /test/genOp.coffee: -------------------------------------------------------------------------------- 1 | 2 | {randomInt, randomWord} = require 'ot-fuzzer' 3 | {type} = require '../lib' 4 | 5 | module.exports = genOp = (docStr) -> 6 | initial = docStr 7 | 8 | op = [] 9 | expectedDoc = '' 10 | 11 | consume = (len) -> 12 | expectedDoc += docStr[...len] 13 | docStr = docStr[len..] 14 | 15 | addInsert = -> 16 | # Insert a random word from the list somewhere in the document 17 | skip = randomInt Math.min docStr.length, 5 18 | word = randomWord() + ' ' 19 | 20 | op.push skip 21 | consume skip 22 | 23 | op.push word 24 | expectedDoc += word 25 | 26 | addDelete = -> 27 | skip = randomInt Math.min docStr.length, 5 28 | 29 | op.push skip 30 | consume skip 31 | 32 | length = randomInt Math.min docStr.length, 10 33 | op.push {d:length} 34 | docStr = docStr[length..] 35 | 36 | while docStr.length > 0 37 | # If the document is long, we'll bias it toward deletes 38 | chance = if initial.length > 100 then 3 else 2 39 | switch randomInt(chance) 40 | when 0 then addInsert() 41 | when 1, 2 then addDelete() 42 | 43 | if randomInt(7) is 0 44 | break 45 | 46 | # The code above will never insert at the end of the document. Its important to do that 47 | # sometimes. 48 | addInsert() if randomInt(10) == 0 49 | 50 | expectedDoc += docStr 51 | [type.normalize(op), expectedDoc] 52 | 53 | 54 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | -r coffeescript/register 2 | --reporter spec 3 | --check-leaks 4 | -------------------------------------------------------------------------------- /test/text-transform-tests.json: -------------------------------------------------------------------------------- 1 | [1,{"d":"abcde"},14] 2 | [7,{"d":"fg"},11] 3 | left 4 | [1,{"d":"abcde"},12] 5 | [7,{"d":"fg"},11] 6 | [1,{"d":"abcde"},14] 7 | right 8 | [2,{"d":"fg"},11] 9 | [7,{"d":"fg"},11] 10 | [1,{"d":"abcde"},14] 11 | left 12 | [2,{"d":"fg"},11] 13 | [1,{"d":"abcde"},14] 14 | [7,{"d":"fg"},11] 15 | right 16 | [1,{"d":"abcde"},12] 17 | [1,{"d":"abcde"},14] 18 | [6,{"d":"fg"},12] 19 | left 20 | [1,{"d":"abcde"},12] 21 | [6,{"d":"fg"},12] 22 | [1,{"d":"abcde"},14] 23 | right 24 | [1,{"d":"fg"},12] 25 | [6,{"d":"fg"},12] 26 | [1,{"d":"abcde"},14] 27 | left 28 | [1,{"d":"fg"},12] 29 | [1,{"d":"abcde"},14] 30 | [6,{"d":"fg"},12] 31 | right 32 | [1,{"d":"abcde"},12] 33 | [1,{"d":"abcde"},14] 34 | [3,{"d":"cdefghi"},10] 35 | left 36 | [1,{"d":"ab"},10] 37 | [3,{"d":"cdefghi"},10] 38 | [1,{"d":"abcde"},14] 39 | right 40 | [1,{"d":"fghi"},10] 41 | [3,{"d":"cdefghi"},10] 42 | [1,{"d":"abcde"},14] 43 | left 44 | [1,{"d":"fghi"},10] 45 | [1,{"d":"abcde"},14] 46 | [3,{"d":"cdefghi"},10] 47 | right 48 | [1,{"d":"ab"},10] 49 | [1,{"d":"abcdefg"},12] 50 | [3,{"d":"cd"},15] 51 | left 52 | [1,{"d":"abefg"},12] 53 | [3,{"d":"cd"},15] 54 | [1,{"d":"abcdefg"},12] 55 | right 56 | [13] 57 | [3,{"d":"cd"},15] 58 | [1,{"d":"abcdefg"},12] 59 | left 60 | [13] 61 | [1,{"d":"abcdefg"},12] 62 | [3,{"d":"cd"},15] 63 | right 64 | [1,{"d":"abefg"},12] 65 | [1,{"d":"abcdefg"},12] 66 | [1,{"d":"abcdefg"},12] 67 | left 68 | [13] 69 | [1,{"d":"abcdefg"},12] 70 | [1,{"d":"abcdefg"},12] 71 | right 72 | [13] 73 | [1,{"d":"abcdefg"},12] 74 | [1,{"d":"abcdefg"},12] 75 | left 76 | [13] 77 | [1,{"d":"abcdefg"},12] 78 | [1,{"d":"abcdefg"},12] 79 | right 80 | [13] 81 | [1,{"i":"abc"},19] 82 | [2,{"d":"de"},16] 83 | left 84 | [1,{"i":"abc"},17] 85 | [2,{"d":"de"},16] 86 | [1,{"i":"abc"},19] 87 | right 88 | [5,{"d":"de"},16] 89 | [2,{"d":"de"},16] 90 | [1,{"i":"abc"},19] 91 | left 92 | [5,{"d":"de"},16] 93 | [1,{"i":"abc"},19] 94 | [2,{"d":"de"},16] 95 | right 96 | [1,{"i":"abc"},17] 97 | [2,{"i":"bcd"},18] 98 | [1,{"d":"ae"},17] 99 | left 100 | [1,{"i":"bcd"},17] 101 | [1,{"d":"ae"},17] 102 | [2,{"i":"bcd"},18] 103 | right 104 | [1,{"d":"a"},3,{"d":"e"},17] 105 | [1,{"d":"ae"},17] 106 | [2,{"i":"bcd"},18] 107 | left 108 | [1,{"d":"a"},3,{"d":"e"},17] 109 | [2,{"i":"bcd"},18] 110 | [1,{"d":"ae"},17] 111 | right 112 | [1,{"i":"bcd"},17] 113 | [1,{"i":"abc"},19] 114 | [1,{"d":"de"},17] 115 | left 116 | [1,{"i":"abc"},17] 117 | [1,{"d":"de"},17] 118 | [1,{"i":"abc"},19] 119 | right 120 | [4,{"d":"de"},17] 121 | [1,{"d":"de"},17] 122 | [1,{"i":"abc"},19] 123 | left 124 | [4,{"d":"de"},17] 125 | [1,{"i":"abc"},19] 126 | [1,{"d":"de"},17] 127 | right 128 | [1,{"i":"abc"},17] 129 | [3,{"i":"abc"},17] 130 | [1,{"d":"de"},17] 131 | left 132 | [1,{"i":"abc"},17] 133 | [1,{"d":"de"},17] 134 | [3,{"i":"abc"},17] 135 | right 136 | [1,{"d":"de"},20] 137 | [1,{"d":"de"},17] 138 | [3,{"i":"abc"},17] 139 | left 140 | [1,{"d":"de"},20] 141 | [3,{"i":"abc"},17] 142 | [1,{"d":"de"},17] 143 | right 144 | [1,{"i":"abc"},17] 145 | [4,{"i":"abc"},16] 146 | [1,{"d":"de"},17] 147 | left 148 | [2,{"i":"abc"},16] 149 | [1,{"d":"de"},17] 150 | [4,{"i":"abc"},16] 151 | right 152 | [1,{"d":"de"},20] 153 | [1,{"d":"de"},17] 154 | [4,{"i":"abc"},16] 155 | left 156 | [1,{"d":"de"},20] 157 | [4,{"i":"abc"},16] 158 | [1,{"d":"de"},17] 159 | right 160 | [2,{"i":"abc"},16] 161 | [1,{"i":"a"},19] 162 | [2,{"i":"b"},18] 163 | left 164 | [1,{"i":"a"},20] 165 | [2,{"i":"b"},18] 166 | [1,{"i":"a"},19] 167 | right 168 | [3,{"i":"b"},18] 169 | [2,{"i":"b"},18] 170 | [1,{"i":"a"},19] 171 | left 172 | [3,{"i":"b"},18] 173 | [1,{"i":"a"},19] 174 | [2,{"i":"b"},18] 175 | right 176 | [1,{"i":"a"},20] 177 | [1,{"i":"abc"},19] 178 | [1,{"i":"1234"},19] 179 | left 180 | [1,{"i":"abc"},23] 181 | [1,{"i":"1234"},19] 182 | [1,{"i":"abc"},19] 183 | right 184 | [4,{"i":"1234"},19] 185 | -------------------------------------------------------------------------------- /test/text.coffee: -------------------------------------------------------------------------------- 1 | # Tests for the ShareDB compatible text type. 2 | 3 | fs = require 'fs' 4 | assert = require 'assert' 5 | 6 | fuzzer = require 'ot-fuzzer' 7 | type = require('../lib').type 8 | genOp = require './genOp' 9 | 10 | readOp = (file) -> 11 | op = for c in JSON.parse file.shift() 12 | if typeof c is 'number' 13 | c 14 | else if c.i? 15 | c.i 16 | else 17 | {d:c.d.length} 18 | 19 | type.normalize op 20 | 21 | 22 | describe 'text', -> 23 | describe 'text-transform-tests.json', -> 24 | it 'should transform correctly', -> 25 | testData = fs.readFileSync(__dirname + '/text-transform-tests.json').toString().split('\n') 26 | 27 | while testData.length >= 4 28 | op = readOp testData 29 | otherOp = readOp testData 30 | side = testData.shift() 31 | expected = readOp testData 32 | 33 | result = type.transform op, otherOp, side 34 | 35 | assert.deepEqual result, expected 36 | 37 | it 'should compose without crashing', -> 38 | testData = fs.readFileSync(__dirname + '/text-transform-tests.json').toString().split('\n') 39 | 40 | while testData.length >= 4 41 | testData.shift() 42 | op1 = readOp testData 43 | testData.shift() 44 | op2 = readOp testData 45 | 46 | # nothing interesting is done with result... This test just makes sure compose runs 47 | # without crashing. 48 | result = type.compose(op1, op2) 49 | 50 | describe '#create()', -> 51 | it 'should return an empty string when called with no arguments', -> 52 | assert.strictEqual '', type.create() 53 | it 'should return any string thats passed in', -> 54 | assert.strictEqual '', type.create '' 55 | assert.strictEqual 'oh hi', type.create 'oh hi' 56 | it 'throws when something other than a string is passed in', -> 57 | assert.throws (-> type.create 123), /must be a string/ 58 | 59 | it 'should normalize sanely', -> 60 | assert.deepEqual [], type.normalize [0] 61 | assert.deepEqual [], type.normalize [''] 62 | assert.deepEqual [], type.normalize [{d:0}] 63 | 64 | assert.deepEqual [{d:2}], type.normalize [{d:2}] 65 | assert.deepEqual [], type.normalize [1,1] 66 | assert.deepEqual [], type.normalize [2,0] 67 | assert.deepEqual [2, 'hi'], type.normalize [1,1,'hi'] 68 | assert.deepEqual [{d:2}, 'hi'], type.normalize [{d:1}, {d:1},'hi'] 69 | assert.deepEqual ['a'], type.normalize ['a', 100] 70 | assert.deepEqual ['ab'], type.normalize ['a', 'b'] 71 | assert.deepEqual ['ab'], type.normalize ['ab', ''] 72 | assert.deepEqual ['ab'], type.normalize [0, 'a', 0, 'b', 0] 73 | assert.deepEqual ['a', 1, 'b'], type.normalize ['a', 1, 'b'] 74 | 75 | describe '#selectionEq', -> 76 | it 'just does equality on plain numbers', -> 77 | assert type.selectionEq 5, 5 78 | assert type.selectionEq 0, 0 79 | assert.equal false, type.selectionEq 0, 1 80 | assert.equal false, type.selectionEq 5, 1 81 | 82 | it 'compares pairs correctly', -> 83 | assert type.selectionEq [1,2], [1,2] 84 | assert type.selectionEq [2,2], [2,2] 85 | assert type.selectionEq [0,0], [0,0] 86 | assert type.selectionEq [0,1], [0,1] 87 | assert type.selectionEq [1,0], [1,0] 88 | 89 | assert.equal false, type.selectionEq [1,2], [1,0] 90 | assert.equal false, type.selectionEq [0,2], [0,1] 91 | assert.equal false, type.selectionEq [1,0], [5,0] 92 | assert.equal false, type.selectionEq [1,1], [5,5] 93 | 94 | it 'works with array vs number', -> 95 | assert type.selectionEq 0, [0,0] 96 | assert type.selectionEq 1, [1,1] 97 | assert type.selectionEq [0,0], 0 98 | assert type.selectionEq [1,1], 1 99 | 100 | assert.equal false, type.selectionEq 1, [1,0] 101 | assert.equal false, type.selectionEq 0, [0,1] 102 | assert.equal false, type.selectionEq [1,2], 1 103 | assert.equal false, type.selectionEq [0,2], 0 104 | 105 | describe '#transformSelection()', -> 106 | # This test was copied from https://github.com/josephg/libot/blob/master/test.c 107 | ins = [10, "oh hi"] 108 | del = [25, {d:20}] 109 | op = [10, 'oh hi', 10, {d:20}] # The previous ops composed together 110 | 111 | tc = (op, isOwn, cursor, expected) -> 112 | assert type.selectionEq expected, type.transformSelection cursor, op, isOwn 113 | assert type.selectionEq expected, type.transformSelection [cursor, cursor], op, isOwn 114 | 115 | it "shouldn't move a cursor at the start of the inserted text", -> 116 | tc op, false, 10, 10 117 | 118 | it "move a cursor at the start of the inserted text if its yours", -> 119 | tc ins, true, 10, 15 120 | 121 | it 'should move a character inside a deleted region to the start of the region', -> 122 | tc del, false, 25, 25 123 | tc del, false, 35, 25 124 | tc del, false, 45, 25 125 | 126 | tc del, true, 25, 25 127 | tc del, true, 35, 25 128 | tc del, true, 45, 25 129 | 130 | it "shouldn't effect cursors before the deleted region", -> 131 | tc del, false, 10, 10 132 | 133 | it "pulls back cursors past the end of the deleted region", -> 134 | tc del, false, 55, 35 135 | 136 | it "teleports your cursor to the end of the last insert or the delete", -> 137 | tc ins, true, 0, 15 138 | tc ins, true, 100, 15 139 | tc del, true, 0, 25 140 | tc del, true, 100, 25 141 | 142 | it "works with more complicated ops", -> 143 | tc op, false, 0, 0 144 | tc op, false, 100, 85 145 | tc op, false, 10, 10 146 | tc op, false, 11, 16 147 | 148 | tc op, false, 20, 25 149 | tc op, false, 30, 25 150 | tc op, false, 40, 25 151 | tc op, false, 41, 26 152 | 153 | 154 | describe 'randomizer', -> it 'passes', -> 155 | @timeout 10000 156 | @slow 1500 157 | 158 | fuzzer type, genOp 159 | 160 | # And test the API. 161 | require('./api') type, genOp 162 | 163 | --------------------------------------------------------------------------------