import * as Y from './testHelper.js' import * as t from 'lib0/testing' import * as prng from 'lib0/prng' import * as math from 'lib0/math' const { init, compare } = Y /** * @param {t.TestCase} tc */ export const testDeltaAfterConcurrentFormatting = tc => { const { text0, text1, testConnector } = init(tc, { users: 2 }) text0.insert(0, 'abcde') testConnector.flushAllMessages() text0.format(0, 3, { bold: true }) text1.format(2, 2, { bold: true }) let delta = null text1.observe(event => { delta = event.delta }) testConnector.flushAllMessages() t.compare(delta, []) } /** * @param {t.TestCase} tc */ export const testBasicInsertAndDelete = tc => { const { users, text0 } = init(tc, { users: 2 }) let delta text0.observe(event => { delta = event.delta }) text0.delete(0, 0) t.assert(true, 'Does not throw when deleting zero elements with position 0') text0.insert(0, 'abc') t.assert(text0.toString() === 'abc', 'Basic insert works') t.compare(delta, [{ insert: 'abc' }]) text0.delete(0, 1) t.assert(text0.toString() === 'bc', 'Basic delete works (position 0)') t.compare(delta, [{ delete: 1 }]) text0.delete(1, 1) t.assert(text0.toString() === 'b', 'Basic delete works (position 1)') t.compare(delta, [{ retain: 1 }, { delete: 1 }]) users[0].transact(() => { text0.insert(0, '1') text0.delete(0, 1) }) t.compare(delta, []) compare(users) } /** * @param {t.TestCase} tc */ export const testBasicFormat = tc => { const { users, text0 } = init(tc, { users: 2 }) let delta text0.observe(event => { delta = event.delta }) text0.insert(0, 'abc', { bold: true }) t.assert(text0.toString() === 'abc', 'Basic insert with attributes works') t.compare(text0.toDelta(), [{ insert: 'abc', attributes: { bold: true } }]) t.compare(delta, [{ insert: 'abc', attributes: { bold: true } }]) text0.delete(0, 1) t.assert(text0.toString() === 'bc', 'Basic delete on formatted works (position 0)') t.compare(text0.toDelta(), [{ insert: 'bc', attributes: { bold: true } }]) t.compare(delta, [{ delete: 1 }]) text0.delete(1, 1) t.assert(text0.toString() === 'b', 'Basic delete works (position 1)') t.compare(text0.toDelta(), [{ insert: 'b', attributes: { bold: true } }]) t.compare(delta, [{ retain: 1 }, { delete: 1 }]) text0.insert(0, 'z', { bold: true }) t.assert(text0.toString() === 'zb') t.compare(text0.toDelta(), [{ insert: 'zb', attributes: { bold: true } }]) t.compare(delta, [{ insert: 'z', attributes: { bold: true } }]) // @ts-ignore t.assert(text0._start.right.right.right.content.str === 'b', 'Does not insert duplicate attribute marker') text0.insert(0, 'y') t.assert(text0.toString() === 'yzb') t.compare(text0.toDelta(), [{ insert: 'y' }, { insert: 'zb', attributes: { bold: true } }]) t.compare(delta, [{ insert: 'y' }]) text0.format(0, 2, { bold: null }) t.assert(text0.toString() === 'yzb') t.compare(text0.toDelta(), [{ insert: 'yz' }, { insert: 'b', attributes: { bold: true } }]) t.compare(delta, [{ retain: 1 }, { retain: 1, attributes: { bold: null } }]) compare(users) } /** * @param {t.TestCase} tc */ export const testMultilineFormat = tc => { const ydoc = new Y.Doc() const testText = ydoc.getText('test') testText.insert(0, 'Test\nMulti-line\nFormatting') testText.applyDelta([ { retain: 4, attributes: { bold: true } }, { retain: 1 }, // newline character { retain: 10, attributes: { bold: true } }, { retain: 1 }, // newline character { retain: 10, attributes: { bold: true } } ]) t.compare(testText.toDelta(), [ { insert: 'Test', attributes: { bold: true } }, { insert: '\n' }, { insert: 'Multi-line', attributes: { bold: true } }, { insert: '\n' }, { insert: 'Formatting', attributes: { bold: true } } ]) } /** * @param {t.TestCase} tc */ export const testNotMergeEmptyLinesFormat = tc => { const ydoc = new Y.Doc() const testText = ydoc.getText('test') testText.applyDelta([ { insert: 'Text' }, { insert: '\n', attributes: { title: true } }, { insert: '\nText' }, { insert: '\n', attributes: { title: true } } ]) t.compare(testText.toDelta(), [ { insert: 'Text' }, { insert: '\n', attributes: { title: true } }, { insert: '\nText' }, { insert: '\n', attributes: { title: true } } ]) } /** * @param {t.TestCase} tc */ export const testGetDeltaWithEmbeds = tc => { const { text0 } = init(tc, { users: 1 }) text0.applyDelta([{ insert: { linebreak: 's' } }]) t.compare(text0.toDelta(), [{ insert: { linebreak: 's' } }]) } /** * @param {t.TestCase} tc */ export const testTypesAsEmbed = tc => { const { text0, text1, testConnector } = init(tc, { users: 2 }) text0.applyDelta([{ insert: new Y.Map([['key', 'val']]) }]) t.compare(text0.toDelta()[0].insert.toJSON(), { key: 'val' }) let firedEvent = false text1.observe(event => { const d = event.delta t.assert(d.length === 1) t.compare(d.map(x => /** @type {Y.AbstractType} */ (x.insert).toJSON()), [{ key: 'val' }]) firedEvent = true }) testConnector.flushAllMessages() const delta = text1.toDelta() t.assert(delta.length === 1) t.compare(delta[0].insert.toJSON(), { key: 'val' }) t.assert(firedEvent, 'fired the event observer containing a Type-Embed') } /** * @param {t.TestCase} tc */ export const testSnapshot = tc => { const { text0 } = init(tc, { users: 1 }) const doc0 = /** @type {Y.Doc} */ (text0.doc) doc0.gc = false text0.applyDelta([{ insert: 'abcd' }]) const snapshot1 = Y.snapshot(doc0) text0.applyDelta([{ retain: 1 }, { insert: 'x' }, { delete: 1 }]) const snapshot2 = Y.snapshot(doc0) text0.applyDelta([{ retain: 2 }, { delete: 3 }, { insert: 'x' }, { delete: 1 }]) const state1 = text0.toDelta(snapshot1) t.compare(state1, [{ insert: 'abcd' }]) const state2 = text0.toDelta(snapshot2) t.compare(state2, [{ insert: 'axcd' }]) const state2Diff = text0.toDelta(snapshot2, snapshot1) // @ts-ignore Remove userid info state2Diff.forEach(v => { if (v.attributes && v.attributes.ychange) { delete v.attributes.ychange.user } }) t.compare(state2Diff, [{ insert: 'a' }, { insert: 'x', attributes: { ychange: { type: 'added' } } }, { insert: 'b', attributes: { ychange: { type: 'removed' } } }, { insert: 'cd' }]) } /** * @param {t.TestCase} tc */ export const testSnapshotDeleteAfter = tc => { const { text0 } = init(tc, { users: 1 }) const doc0 = /** @type {Y.Doc} */ (text0.doc) doc0.gc = false text0.applyDelta([{ insert: 'abcd' }]) const snapshot1 = Y.snapshot(doc0) text0.applyDelta([{ retain: 4 }, { insert: 'e' }]) const state1 = text0.toDelta(snapshot1) t.compare(state1, [{ insert: 'abcd' }]) } /** * @param {t.TestCase} tc */ export const testToJson = tc => { const { text0 } = init(tc, { users: 1 }) text0.insert(0, 'abc', { bold: true }) t.assert(text0.toJSON() === 'abc', 'toJSON returns the unformatted text') } /** * @param {t.TestCase} tc */ export const testToDeltaEmbedAttributes = tc => { const { text0 } = init(tc, { users: 1 }) text0.insert(0, 'ab', { bold: true }) text0.insertEmbed(1, { image: 'imageSrc.png' }, { width: 100 }) const delta0 = text0.toDelta() t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' }, attributes: { width: 100 } }, { insert: 'b', attributes: { bold: true } }]) } /** * @param {t.TestCase} tc */ export const testToDeltaEmbedNoAttributes = tc => { const { text0 } = init(tc, { users: 1 }) text0.insert(0, 'ab', { bold: true }) text0.insertEmbed(1, { image: 'imageSrc.png' }) const delta0 = text0.toDelta() t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' } }, { insert: 'b', attributes: { bold: true } }], 'toDelta does not set attributes key when no attributes are present') } /** * @param {t.TestCase} tc */ export const testFormattingRemoved = tc => { const { text0 } = init(tc, { users: 1 }) text0.insert(0, 'ab', { bold: true }) text0.delete(0, 2) t.assert(Y.getTypeChildren(text0).length === 1) } /** * @param {t.TestCase} tc */ export const testFormattingRemovedInMidText = tc => { const { text0 } = init(tc, { users: 1 }) text0.insert(0, '1234') text0.insert(2, 'ab', { bold: true }) text0.delete(2, 2) t.assert(Y.getTypeChildren(text0).length === 3) } /** * Reported in https://github.com/yjs/yjs/issues/344 * * @param {t.TestCase} tc */ export const testFormattingDeltaUnnecessaryAttributeChange = tc => { const { text0, text1, testConnector } = init(tc, { users: 2 }) text0.insert(0, '\n', { PARAGRAPH_STYLES: 'normal', LIST_STYLES: 'bullet' }) text0.insert(1, 'abc', { PARAGRAPH_STYLES: 'normal' }) testConnector.flushAllMessages() /** * @type {Array} */ const deltas = [] text0.observe(event => { deltas.push(event.delta) }) text1.observe(event => { deltas.push(event.delta) }) text1.format(0, 1, { LIST_STYLES: 'number' }) testConnector.flushAllMessages() const filteredDeltas = deltas.filter(d => d.length > 0) t.assert(filteredDeltas.length === 2) t.compare(filteredDeltas[0], [ { retain: 1, attributes: { LIST_STYLES: 'number' } } ]) t.compare(filteredDeltas[0], filteredDeltas[1]) } /** * @param {t.TestCase} tc */ export const testInsertAndDeleteAtRandomPositions = tc => { // @todo optimize to run at least as fast as previous marker approach const N = 100000 const { text0 } = init(tc, { users: 1 }) const gen = tc.prng // create initial content // let expectedResult = init text0.insert(0, prng.word(gen, N / 2, N / 2)) // apply changes for (let i = 0; i < N; i++) { const pos = prng.uint32(gen, 0, text0.length) if (prng.bool(gen)) { const len = prng.uint32(gen, 1, 5) const word = prng.word(gen, 0, len) text0.insert(pos, word) // expectedResult = expectedResult.slice(0, pos) + word + expectedResult.slice(pos) } else { const len = prng.uint32(gen, 0, math.min(3, text0.length - pos)) text0.delete(pos, len) // expectedResult = expectedResult.slice(0, pos) + expectedResult.slice(pos + len) } } // t.compareStrings(text0.toString(), expectedResult) t.describe('final length', '' + text0.length) } /** * @param {t.TestCase} tc */ export const testAppendChars = tc => { const N = 10000 const { text0 } = init(tc, { users: 1 }) // apply changes for (let i = 0; i < N; i++) { text0.insert(text0.length, 'a') } t.assert(text0.length === N) } const largeDocumentSize = 100000 const id = Y.createID(0, 0) const c = new Y.ContentString('a') /** * @param {t.TestCase} tc */ export const testBestCase = tc => { const N = largeDocumentSize const items = new Array(N) t.measureTime('time to create two million items in the best case', () => { const parent = /** @type {any} */ ({}) let prevItem = null for (let i = 0; i < N; i++) { /** * @type {Y.Item} */ const n = new Y.Item(Y.createID(0, 0), null, null, null, null, null, null, c) // items.push(n) items[i] = n n.right = prevItem n.rightOrigin = prevItem ? id : null n.content = c n.parent = parent prevItem = n } }) const newArray = new Array(N) t.measureTime('time to copy two million items to new Array', () => { for (let i = 0; i < N; i++) { newArray[i] = items[i] } }) } const tryGc = () => { // @ts-ignore if (typeof global !== 'undefined' && global.gc) { // @ts-ignore global.gc() } } /** * @param {t.TestCase} tc */ export const testLargeFragmentedDocument = tc => { const itemsToInsert = largeDocumentSize let update = /** @type {any} */ (null) ;(() => { const doc1 = new Y.Doc() const text0 = doc1.getText('txt') tryGc() t.measureTime(`time to insert ${itemsToInsert} items`, () => { doc1.transact(() => { for (let i = 0; i < itemsToInsert; i++) { text0.insert(0, '0') } }) }) tryGc() t.measureTime('time to encode document', () => { update = Y.encodeStateAsUpdateV2(doc1) }) t.describe('Document size:', update.byteLength) })() ;(() => { const doc2 = new Y.Doc() tryGc() t.measureTime(`time to apply ${itemsToInsert} updates`, () => { Y.applyUpdateV2(doc2, update) }) })() } /** * @param {t.TestCase} tc */ export const testIncrementalUpdatesPerformanceOnLargeFragmentedDocument = tc => { const itemsToInsert = largeDocumentSize const updates = /** @type {Array} */ ([]) ;(() => { const doc1 = new Y.Doc() doc1.on('update', update => { updates.push(update) }) const text0 = doc1.getText('txt') tryGc() t.measureTime(`time to insert ${itemsToInsert} items`, () => { doc1.transact(() => { for (let i = 0; i < itemsToInsert; i++) { text0.insert(0, '0') } }) }) tryGc() })() ;(() => { t.measureTime(`time to merge ${itemsToInsert} updates (differential updates)`, () => { Y.mergeUpdates(updates) }) tryGc() t.measureTime(`time to merge ${itemsToInsert} updates (ydoc updates)`, () => { const ydoc = new Y.Doc() updates.forEach(update => { Y.applyUpdate(ydoc, update) }) }) })() } /** * Splitting surrogates can lead to invalid encoded documents. * * https://github.com/yjs/yjs/issues/248 * * @param {t.TestCase} tc */ export const testSplitSurrogateCharacter = tc => { { const { users, text0 } = init(tc, { users: 2 }) users[1].disconnect() // disconnecting forces the user to encode the split surrogate text0.insert(0, '👾') // insert surrogate character // split surrogate, which should not lead to an encoding error text0.insert(1, 'hi!') compare(users) } { const { users, text0 } = init(tc, { users: 2 }) users[1].disconnect() // disconnecting forces the user to encode the split surrogate text0.insert(0, '👾👾') // insert surrogate character // partially delete surrogate text0.delete(1, 2) compare(users) } { const { users, text0 } = init(tc, { users: 2 }) users[1].disconnect() // disconnecting forces the user to encode the split surrogate text0.insert(0, '👾👾') // insert surrogate character // formatting will also split surrogates text0.format(1, 2, { bold: true }) compare(users) } } /** * Search marker bug https://github.com/yjs/yjs/issues/307 * * @param {t.TestCase} tc */ export const testSearchMarkerBug1 = tc => { const { users, text0, text1, testConnector } = init(tc, { users: 2 }) users[0].on('update', update => { users[0].transact(() => { Y.applyUpdate(users[0], update) }) }) users[0].on('update', update => { users[1].transact(() => { Y.applyUpdate(users[1], update) }) }) text0.insert(0, 'a_a') testConnector.flushAllMessages() text0.insert(2, 's') testConnector.flushAllMessages() text1.insert(3, 'd') testConnector.flushAllMessages() text0.delete(0, 5) testConnector.flushAllMessages() text0.insert(0, 'a_a') testConnector.flushAllMessages() text0.insert(2, 's') testConnector.flushAllMessages() text1.insert(3, 'd') testConnector.flushAllMessages() t.compareStrings(text0.toString(), text1.toString()) t.compareStrings(text0.toString(), 'a_sda') compare(users) } /** * @param {t.TestCase} tc */ export const testFormattingBug = async tc => { const ydoc1 = new Y.Doc() const ydoc2 = new Y.Doc() const text1 = ydoc1.getText() text1.insert(0, '\n\n\n') text1.format(0, 3, { url: 'http://example.com' }) ydoc1.getText().format(1, 1, { url: 'http://docs.yjs.dev' }) Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc1)) const text2 = ydoc2.getText() const expectedResult = [ { insert: '\n', attributes: { url: 'http://example.com' } }, { insert: '\n', attributes: { url: 'http://docs.yjs.dev' } }, { insert: '\n', attributes: { url: 'http://example.com' } } ] t.compare(text1.toDelta(), expectedResult) t.compare(text1.toDelta(), text2.toDelta()) console.log(text1.toDelta()) } // RANDOM TESTS let charCounter = 0 /** * Random tests for pure text operations without formatting. * * @type Array */ const textChanges = [ /** * @param {Y.Doc} y * @param {prng.PRNG} gen */ (y, gen) => { // insert text const ytext = y.getText('text') const insertPos = prng.int32(gen, 0, ytext.length) const text = charCounter++ + prng.word(gen) const prevText = ytext.toString() ytext.insert(insertPos, text) t.compareStrings(ytext.toString(), prevText.slice(0, insertPos) + text + prevText.slice(insertPos)) }, /** * @param {Y.Doc} y * @param {prng.PRNG} gen */ (y, gen) => { // delete text const ytext = y.getText('text') const contentLen = ytext.toString().length const insertPos = prng.int32(gen, 0, contentLen) const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2) const prevText = ytext.toString() ytext.delete(insertPos, overwrite) t.compareStrings(ytext.toString(), prevText.slice(0, insertPos) + prevText.slice(insertPos + overwrite)) } ] /** * @param {t.TestCase} tc */ export const testRepeatGenerateTextChanges5 = tc => { const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 5)) const cleanups = Y.cleanupYTextFormatting(users[0].getText('text')) t.assert(cleanups === 0) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateTextChanges30 = tc => { const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 30)) const cleanups = Y.cleanupYTextFormatting(users[0].getText('text')) t.assert(cleanups === 0) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateTextChanges40 = tc => { const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 40)) const cleanups = Y.cleanupYTextFormatting(users[0].getText('text')) t.assert(cleanups === 0) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateTextChanges50 = tc => { const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 50)) const cleanups = Y.cleanupYTextFormatting(users[0].getText('text')) t.assert(cleanups === 0) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateTextChanges70 = tc => { const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 70)) const cleanups = Y.cleanupYTextFormatting(users[0].getText('text')) t.assert(cleanups === 0) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateTextChanges90 = tc => { const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 90)) const cleanups = Y.cleanupYTextFormatting(users[0].getText('text')) t.assert(cleanups === 0) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateTextChanges300 = tc => { const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 300)) const cleanups = Y.cleanupYTextFormatting(users[0].getText('text')) t.assert(cleanups === 0) } const marks = [ { bold: true }, { italic: true }, { italic: true, color: '#888' } ] const marksChoices = [ undefined, ...marks ] /** * Random tests for all features of y-text (formatting, embeds, ..). * * @type Array */ const qChanges = [ /** * @param {Y.Doc} y * @param {prng.PRNG} gen */ (y, gen) => { // insert text const ytext = y.getText('text') const insertPos = prng.int32(gen, 0, ytext.length) const attrs = prng.oneOf(gen, marksChoices) const text = charCounter++ + prng.word(gen) ytext.insert(insertPos, text, attrs) }, /** * @param {Y.Doc} y * @param {prng.PRNG} gen */ (y, gen) => { // insert embed const ytext = y.getText('text') const insertPos = prng.int32(gen, 0, ytext.length) if (prng.bool(gen)) { ytext.insertEmbed(insertPos, { image: 'https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png' }) } else { ytext.insertEmbed(insertPos, new Y.Map([[prng.word(gen), prng.word(gen)]])) } }, /** * @param {Y.Doc} y * @param {prng.PRNG} gen */ (y, gen) => { // delete text const ytext = y.getText('text') const contentLen = ytext.toString().length const insertPos = prng.int32(gen, 0, contentLen) const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2) ytext.delete(insertPos, overwrite) }, /** * @param {Y.Doc} y * @param {prng.PRNG} gen */ (y, gen) => { // format text const ytext = y.getText('text') const contentLen = ytext.toString().length const insertPos = prng.int32(gen, 0, contentLen) const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2) const format = prng.oneOf(gen, marks) ytext.format(insertPos, overwrite, format) }, /** * @param {Y.Doc} y * @param {prng.PRNG} gen */ (y, gen) => { // insert codeblock const ytext = y.getText('text') const insertPos = prng.int32(gen, 0, ytext.toString().length) const text = charCounter++ + prng.word(gen) const ops = [] if (insertPos > 0) { ops.push({ retain: insertPos }) } ops.push({ insert: text }, { insert: '\n', format: { 'code-block': true } }) ytext.applyDelta(ops) } ] /** * @param {any} result */ const checkResult = result => { for (let i = 1; i < result.testObjects.length; i++) { /** * @param {any} d */ const typeToObject = d => d.insert instanceof Y.AbstractType ? d.insert.toJSON() : d const p1 = result.users[i].getText('text').toDelta().map(typeToObject) const p2 = result.users[i].getText('text').toDelta().map(typeToObject) t.compare(p1, p2) } // Uncomment this to find formatting-cleanup issues // const cleanups = Y.cleanupYTextFormatting(result.users[0].getText('text')) // t.assert(cleanups === 0) return result } /** * @param {t.TestCase} tc */ export const testRepeatGenerateQuillChanges1 = tc => { const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 1)) const cleanups = Y.cleanupYTextFormatting(users[0].getText('text')) t.assert(cleanups === 0) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateQuillChanges2 = tc => { const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2)) const cleanups = Y.cleanupYTextFormatting(users[0].getText('text')) t.assert(cleanups === 0) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateQuillChanges2Repeat = tc => { for (let i = 0; i < 1000; i++) { const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2)) const cleanups = Y.cleanupYTextFormatting(users[0].getText('text')) t.assert(cleanups === 0) } } /** * @param {t.TestCase} tc */ export const testRepeatGenerateQuillChanges3 = tc => { checkResult(Y.applyRandomTests(tc, qChanges, 3)) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateQuillChanges30 = tc => { checkResult(Y.applyRandomTests(tc, qChanges, 30)) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateQuillChanges40 = tc => { checkResult(Y.applyRandomTests(tc, qChanges, 40)) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateQuillChanges70 = tc => { checkResult(Y.applyRandomTests(tc, qChanges, 70)) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateQuillChanges100 = tc => { checkResult(Y.applyRandomTests(tc, qChanges, 100)) } /** * @param {t.TestCase} tc */ export const testRepeatGenerateQuillChanges300 = tc => { checkResult(Y.applyRandomTests(tc, qChanges, 300)) }