diff --git a/src/static/js/Changeset.js b/src/static/js/Changeset.js index 0779884c0a3..3bc61fa577b 100644 --- a/src/static/js/Changeset.js +++ b/src/static/js/Changeset.js @@ -525,6 +525,9 @@ exports.mergingOpAssembler = () => { // ops immediately after it. let bufOpAdditionalCharsAfterNewline = 0; + /** + * @param {boolean} [isEndDocument] + */ const flush = (isEndDocument) => { if (!bufOp.opcode) return; if (isEndDocument && bufOp.opcode === '=' && !bufOp.attribs) { @@ -676,6 +679,7 @@ exports.stringIterator = (str) => { */ exports.stringAssembler = () => ({ _str: '', + clear() { this._str = ''; }, /** * @param {string} x - */ @@ -802,7 +806,7 @@ class TextLinesMutator { /** * Indicates if curLine is already in the splice. This is necessary because the last element in - * curSplice is curLine when this line is currently worked on (e.g. when skipping are inserting). + * curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting). * * TODO(doc) why aren't removals considered? * @@ -830,7 +834,7 @@ class TextLinesMutator { * It will skip some newlines by putting them into the splice. * * @param {number} L - - * @param {boolean} includeInSplice - indicates if attributes are present + * @param {boolean} includeInSplice - Indicates that attributes are present. */ skipLines(L, includeInSplice) { if (!L) return; @@ -959,7 +963,7 @@ class TextLinesMutator { /** @type {string} */ const theLine = this._curSplice[sline]; const lineCol = this._curCol; - // insert the first new line + // Insert the chars up to `curCol` and the first new line. this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; this._curLine++; newLines.splice(0, 1); @@ -975,9 +979,8 @@ class TextLinesMutator { this._curLine += newLines.length; } } else { - // there are no additional lines - // although the line is put into splice, curLine is not increased, because - // there may be more chars in the line (newline is not reached) + // There are no additional lines. Although the line is put into splice, curLine is not + // increased because there may be more chars in the line (newline is not reached). const sline = this._putCurLineInSplice(); if (!this._curSplice[sline]) { const err = new Error( @@ -1276,6 +1279,13 @@ exports.applyToAttribution = (cs, astr, pool) => { return applyZip(astr, unpacked.ops, (op1, op2) => slicerZipperFunc(op1, op2, pool)); }; +/** + * Applies a changeset to an array of attribute lines. + * + * @param {string} cs - The encoded changeset. + * @param {Array} lines - Attribute lines. Modified in place. + * @param {AttributePool} pool - Attribute pool. + */ exports.mutateAttributionLines = (cs, lines, pool) => { const unpacked = exports.unpack(cs); const csOps = exports.deserializeOps(unpacked.ops); @@ -1285,26 +1295,47 @@ exports.mutateAttributionLines = (cs, lines, pool) => { // treat the attribution lines as text lines, mutating a line at a time const mut = new TextLinesMutator(lines); - /** @type {?Generator} */ + /** + * The Ops in the current line from `lines`. + * + * @type {?Generator} + */ let lineOps = null; let lineOpsNext = null; const lineOpsHasNext = () => lineOpsNext && !lineOpsNext.done; + /** + * Returns false if we are on the last attribute line in `lines` and there is no additional op in + * that line. + * + * @returns {boolean} True if there are more ops to go through. + */ const isNextMutOp = () => lineOpsHasNext() || mut.hasMore(); + /** + * @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to + * iterate over the next line, which is consumed from `mut`. If there are no more lines, + * returns a null Op. + */ const nextMutOp = () => { if (!lineOpsHasNext() && mut.hasMore()) { + // There are more attribute lines in `lines` to do AND either we just started so `lineIter` is + // still null or there are no more ops in current `lineIter`. const line = mut.removeLines(1); lineOps = exports.deserializeOps(line); lineOpsNext = lineOps.next(); } - if (!lineOpsHasNext()) return new Op(); + if (!lineOpsHasNext()) return new Op(); // No more ops and no more lines. const op = lineOpsNext.value; lineOpsNext = lineOps.next(); return op; }; let lineAssem = null; + /** + * Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the + * `lines` mutator. + */ const outputMutOp = (op) => { if (!lineAssem) { lineAssem = exports.mergingOpAssembler(); @@ -1321,6 +1352,7 @@ exports.mutateAttributionLines = (cs, lines, pool) => { let attOp = new Op(); while (csOp.opcode || !csOpsNext.done || attOp.opcode || isNextMutOp()) { if (!csOp.opcode && !csOpsNext.done) { + // coOp done, but more ops in cs. csOp = csOpsNext.value; csOpsNext = csOps.next(); } @@ -1328,18 +1360,21 @@ exports.mutateAttributionLines = (cs, lines, pool) => { break; // done } else if (csOp.opcode === '=' && csOp.lines > 0 && !csOp.attribs && !attOp.opcode && !lineAssem && !lineOpsHasNext()) { - // skip multiple lines; this is what makes small changes not order of the document size + // Skip multiple lines without attributes; this is what makes small changes not order of the + // document size. mut.skipLines(csOp.lines); csOp.opcode = ''; } else if (csOp.opcode === '+') { const opOut = copyOp(csOp); if (csOp.lines > 1) { + // Copy the first line from `csOp` to `opOut`. const firstLineLen = csBank.indexOf('\n', csBankIndex) + 1 - csBankIndex; csOp.chars -= firstLineLen; csOp.lines--; opOut.lines = 1; opOut.chars = firstLineLen; } else { + // Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`. csOp.opcode = ''; } outputMutOp(opOut); @@ -1763,7 +1798,7 @@ exports.copyAText = (atext1, atext2) => { }; /** - * Convert AText to a series of operations. + * Convert AText to a series of operations. Strips final newline. * * @param {AText} atext - The AText to convert. * @yields {Op} diff --git a/src/tests/frontend/easysync-helper.js b/src/tests/frontend/easysync-helper.js index 6b4bfc95982..b4f77096301 100644 --- a/src/tests/frontend/easysync-helper.js +++ b/src/tests/frontend/easysync-helper.js @@ -48,7 +48,7 @@ exports.randomMultiline = randomMultiline; const randomStringOperation = (numCharsLeft) => { let result; - switch (randInt(9)) { + switch (randInt(11)) { case 0: { // insert char diff --git a/src/tests/frontend/specs/easysync-assembler.js b/src/tests/frontend/specs/easysync-assembler.js index d9ce04ae2ac..070e1bd8bba 100644 --- a/src/tests/frontend/specs/easysync-assembler.js +++ b/src/tests/frontend/specs/easysync-assembler.js @@ -1,6 +1,7 @@ 'use strict'; const Changeset = require('../../../static/js/Changeset'); +const {poolOrArray} = require('../easysync-helper.js'); describe('easysync-assembler', function () { it('opAssembler', async function () { @@ -18,6 +19,137 @@ describe('easysync-assembler', function () { expect(assem.toString()).to.equal(x); }); + it('smartOpAssembler ignore additional pure keeps (no attributes)', async function () { + const x = '-c*3*4+6|1+1=5'; + const iter = Changeset.opIterator(x); + const assem = Changeset.smartOpAssembler(); + while (iter.hasNext()) assem.append(iter.next()); + assem.endDocument(); + expect(assem.toString()).to.equal('-c*3*4+6|1+1'); + }); + + it('smartOpAssembler merge consecutive + ops without multiline', async function () { + const x = '-c*3*4+6*3*4+1*3*4+9=5'; + const iter = Changeset.opIterator(x); + const assem = Changeset.smartOpAssembler(); + while (iter.hasNext()) assem.append(iter.next()); + assem.endDocument(); + expect(assem.toString()).to.equal('-c*3*4+g'); + }); + + it('smartOpAssembler merge consecutive + ops with multiline', async function () { + const x = '-c*3*4+6*3*4|1+1*3*4|9+f*3*4+k=5'; + const iter = Changeset.opIterator(x); + const assem = Changeset.smartOpAssembler(); + while (iter.hasNext()) assem.append(iter.next()); + assem.endDocument(); + expect(assem.toString()).to.equal('-c*3*4|a+m*3*4+k'); + }); + + it('smartOpAssembler merge consecutive - ops without multiline', async function () { + const x = '-c-6-1-9=5'; + const iter = Changeset.opIterator(x); + const assem = Changeset.smartOpAssembler(); + while (iter.hasNext()) assem.append(iter.next()); + assem.endDocument(); + expect(assem.toString()).to.equal('-s'); + }); + + it('smartOpAssembler merge consecutive - ops with multiline', async function () { + const x = '-c-6|1-1|9-f-k=5'; + const iter = Changeset.opIterator(x); + const assem = Changeset.smartOpAssembler(); + while (iter.hasNext()) assem.append(iter.next()); + assem.endDocument(); + expect(assem.toString()).to.equal('|a-y-k'); + }); + + it('smartOpAssembler merge consecutive = ops without multiline', async function () { + const x = '-c*3*4=6*2*4=1*3*4=f*3*4=2*3*4=a=k=5'; + const iter = Changeset.opIterator(x); + const assem = Changeset.smartOpAssembler(); + while (iter.hasNext()) assem.append(iter.next()); + assem.endDocument(); + expect(assem.toString()).to.equal('-c*3*4=6*2*4=1*3*4=r'); + }); + + it('smartOpAssembler merge consecutive = ops with multiline', async function () { + const x = '-c*3*4=6*2*4|1=1*3*4|9=f*3*4|2=2*3*4=a*3*4=1=k=5'; + const iter = Changeset.opIterator(x); + const assem = Changeset.smartOpAssembler(); + while (iter.hasNext()) assem.append(iter.next()); + assem.endDocument(); + expect(assem.toString()).to.equal('-c*3*4=6*2*4|1=1*3*4|b=h*3*4=b'); + }); + + it('smartOpAssembler ignore + ops with ops.chars === 0', async function () { + const x = '-c*3*4+6*3*4+0*3*4+1+0*3*4+1'; + const iter = Changeset.opIterator(x); + const assem = Changeset.smartOpAssembler(); + while (iter.hasNext()) assem.append(iter.next()); + assem.endDocument(); + expect(assem.toString()).to.equal('-c*3*4+8'); + }); + + it('smartOpAssembler ignore - ops with ops.chars === 0', async function () { + const x = '-c-6-0-1-0-1'; + const iter = Changeset.opIterator(x); + const assem = Changeset.smartOpAssembler(); + while (iter.hasNext()) assem.append(iter.next()); + assem.endDocument(); + expect(assem.toString()).to.equal('-k'); + }); + + it('smartOpAssembler append + op with text', async function () { + const assem = Changeset.smartOpAssembler(); + const pool = poolOrArray([ + 'attr1,1', + 'attr2,2', + 'attr3,3', + 'attr4,4', + 'attr5,5', + ]); + + assem.appendOpWithText('+', 'test', '*3*4*5', pool); + assem.appendOpWithText('+', 'test', '*3*4*5', pool); + assem.appendOpWithText('+', 'test', '*1*4*5', pool); + assem.endDocument(); + expect(assem.toString()).to.equal('*3*4*5+8*1*4*5+4'); + }); + + it('smartOpAssembler append + op with multiline text', async function () { + const assem = Changeset.smartOpAssembler(); + const pool = poolOrArray([ + 'attr1,1', + 'attr2,2', + 'attr3,3', + 'attr4,4', + 'attr5,5', + ]); + + assem.appendOpWithText('+', 'test\ntest', '*3*4*5', pool); + assem.appendOpWithText('+', '\ntest\n', '*3*4*5', pool); + assem.appendOpWithText('+', '\ntest', '*1*4*5', pool); + assem.endDocument(); + expect(assem.toString()).to.equal('*3*4*5|3+f*1*4*5|1+1*1*4*5+4'); + }); + + it('smartOpAssembler clear should empty internal assemblers', async function () { + const x = '-c*3*4+6|3=az*asdf0*1*2*3+1=1-1+1*0+1=1-1+1|c=c-1'; + const iter = Changeset.opIterator(x); + const assem = Changeset.smartOpAssembler(); + assem.append(iter.next()); + assem.append(iter.next()); + assem.append(iter.next()); + assem.clear(); + assem.append(iter.next()); + assem.append(iter.next()); + assem.clear(); + while (iter.hasNext()) assem.append(iter.next()); + assem.endDocument(); + expect(assem.toString()).to.equal('-1+1*0+1=1-1+1|c=c-1'); + }); + describe('append atext to assembler', function () { const testAppendATextToAssembler = (testId, atext, correctOps) => { it(`testAppendATextToAssembler#${testId}`, async function () { diff --git a/src/tests/frontend/specs/easysync-mutations.js b/src/tests/frontend/specs/easysync-mutations.js index 7cf43c8b7e5..06aa63dab9a 100644 --- a/src/tests/frontend/specs/easysync-mutations.js +++ b/src/tests/frontend/specs/easysync-mutations.js @@ -188,6 +188,15 @@ describe('easysync-mutations', function () { testMutateTextLines(1, 'Z:4<1|1-2-1|1+1+1$\nc', ['a\n', 'b\n'], ['\n', 'c\n']); testMutateTextLines(2, 'Z:4>0|1-2-1|2+3$\nc\n', ['a\n', 'b\n'], ['\n', 'c\n', '\n']); + + it('mutate keep only lines', async function () { + const lines = ['1\n', '2\n', '3\n', '4\n']; + const result = lines.slice(); + const cs = 'Z:8>0*0|1=2|2=2'; + + Changeset.mutateTextLines(cs, lines); + expect(result).to.eql(lines); + }); }); describe('mutate attributions', function () {