From 595dc82543387568f3ff51e09dd2cdbb425bd392 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Jul 2019 18:09:31 +0200 Subject: [PATCH 01/15] unit test + fixes + comments + simplication for diffDeletion --- src/editor/diff.js | 24 +++----------------- test/editor/diff-test.js | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 21 deletions(-) create mode 100644 test/editor/diff-test.js diff --git a/src/editor/diff.js b/src/editor/diff.js index 2c82e22793..3b7b2aa79d 100644 --- a/src/editor/diff.js +++ b/src/editor/diff.js @@ -25,16 +25,6 @@ function firstDiff(a, b) { return compareLen; } -function lastDiff(a, b) { - const compareLen = Math.min(a.length, b.length); - for (let i = 0; i < compareLen; ++i) { - if (a[a.length - i] !== b[b.length - i]) { - return i; - } - } - return compareLen; -} - function diffStringsAtEnd(oldStr, newStr) { const len = Math.min(oldStr.length, newStr.length); const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len); @@ -52,22 +42,14 @@ function diffStringsAtEnd(oldStr, newStr) { } } +// assumes only characters have been deleted at one location in the string, and none added export function diffDeletion(oldStr, newStr) { if (oldStr === newStr) { return {}; } const firstDiffIdx = firstDiff(oldStr, newStr); - const lastDiffIdx = oldStr.length - lastDiff(oldStr, newStr) + 1; - return {at: firstDiffIdx, removed: oldStr.substring(firstDiffIdx, lastDiffIdx)}; -} - -export function diffInsertion(oldStr, newStr) { - const diff = diffDeletion(newStr, oldStr); - if (diff.removed) { - return {at: diff.at, added: diff.removed}; - } else { - return diff; - } + const amount = oldStr.length - newStr.length; + return {at: firstDiffIdx, removed: oldStr.substr(firstDiffIdx, amount)}; } export function diffAtCaret(oldValue, newValue, caretPosition) { diff --git a/test/editor/diff-test.js b/test/editor/diff-test.js new file mode 100644 index 0000000000..257c35c22f --- /dev/null +++ b/test/editor/diff-test.js @@ -0,0 +1,48 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import expect from 'expect'; +import {diffDeletion} from "../../src/editor/diff"; + +describe('editor/diff', function() { + describe('diffDeletion', function() { + it('at start of string', function() { + const diff = diffDeletion("hello", "ello"); + expect(diff.at).toBe(0); + expect(diff.removed).toBe("h"); + }); + it('removing whole string', function() { + const diff = diffDeletion("hello", ""); + expect(diff.at).toBe(0); + expect(diff.removed).toBe("hello"); + }); + it('in middle of string', function() { + const diff = diffDeletion("hello", "hllo"); + expect(diff.at).toBe(1); + expect(diff.removed).toBe("e"); + }); + it('in middle of string with duplicate character', function() { + const diff = diffDeletion("hello", "helo"); + expect(diff.at).toBe(3); + expect(diff.removed).toBe("l"); + }); + it('at end of string', function() { + const diff = diffDeletion("hello", "hell"); + expect(diff.at).toBe(4); + expect(diff.removed).toBe("o"); + }); + }); +}); From 419c8001679d167e9165c07d3432a612a03ca838 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 24 Jul 2019 17:26:18 +0200 Subject: [PATCH 02/15] add tests for diffAtCaret --- src/editor/diff.js | 7 ++++ test/editor/diff-test.js | 76 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/editor/diff.js b/src/editor/diff.js index 3b7b2aa79d..b6839b1a88 100644 --- a/src/editor/diff.js +++ b/src/editor/diff.js @@ -52,6 +52,13 @@ export function diffDeletion(oldStr, newStr) { return {at: firstDiffIdx, removed: oldStr.substr(firstDiffIdx, amount)}; } +/** + * Calculates which string was added and removed around the caret position + * @param {String} oldValue the previous value + * @param {String} newValue the new value + * @param {Number} caretPosition the position of the caret after `newValue` was applied. + * @return {object} + */ export function diffAtCaret(oldValue, newValue, caretPosition) { const diffLen = newValue.length - oldValue.length; const caretPositionBeforeInput = caretPosition - diffLen; diff --git a/test/editor/diff-test.js b/test/editor/diff-test.js index 257c35c22f..966e4db6cc 100644 --- a/test/editor/diff-test.js +++ b/test/editor/diff-test.js @@ -15,7 +15,7 @@ limitations under the License. */ import expect from 'expect'; -import {diffDeletion} from "../../src/editor/diff"; +import {diffDeletion, diffAtCaret} from "../../src/editor/diff"; describe('editor/diff', function() { describe('diffDeletion', function() { @@ -45,4 +45,78 @@ describe('editor/diff', function() { expect(diff.removed).toBe("o"); }); }); + describe('diffAtCaret', function() { + it('insert at start', function() { + const diff = diffAtCaret("world", "hello world", 6); + expect(diff.at).toBe(0); + expect(diff.added).toBe("hello "); + expect(diff.removed).toBeFalsy(); + }); + it('insert at end', function() { + const diff = diffAtCaret("hello", "hello world", 11); + expect(diff.at).toBe(5); + expect(diff.added).toBe(" world"); + expect(diff.removed).toBeFalsy(); + }); + it('insert in middle', function() { + const diff = diffAtCaret("hello world", "hello cruel world", 12); + expect(diff.at).toBe(6); + expect(diff.added).toBe("cruel "); + expect(diff.removed).toBeFalsy(); + }); + it('replace at start', function() { + const diff = diffAtCaret("morning, world!", "afternoon, world!", 9); + expect(diff.at).toBe(0); + expect(diff.removed).toBe("morning"); + expect(diff.added).toBe("afternoon"); + }); + it('replace at end', function() { + const diff = diffAtCaret("morning, world!", "morning, mars?", 14); + expect(diff.at).toBe(9); + expect(diff.removed).toBe("world!"); + expect(diff.added).toBe("mars?"); + }); + it('replace in middle', function() { + const diff = diffAtCaret("morning, blue planet", "morning, red planet", 12); + expect(diff.at).toBe(9); + expect(diff.removed).toBe("blue"); + expect(diff.added).toBe("red"); + }); + it('remove at start of string', function() { + const diff = diffAtCaret("hello", "ello", 0); + expect(diff.at).toBe(0); + expect(diff.removed).toBe("h"); + expect(diff.added).toBeFalsy(); + }); + it('removing whole string', function() { + const diff = diffAtCaret("hello", "", 0); + expect(diff.at).toBe(0); + expect(diff.removed).toBe("hello"); + expect(diff.added).toBeFalsy(); + }); + it('remove in middle of string', function() { + const diff = diffAtCaret("hello", "hllo", 1); + expect(diff.at).toBe(1); + expect(diff.removed).toBe("e"); + expect(diff.added).toBeFalsy(); + }); + it('forwards remove in middle of string', function() { + const diff = diffAtCaret("hello", "hell", 4); + expect(diff.at).toBe(4); + expect(diff.removed).toBe("o"); + expect(diff.added).toBeFalsy(); + }); + it('forwards remove in middle of string with duplicate character', function() { + const diff = diffAtCaret("hello", "helo", 3); + expect(diff.at).toBe(3); + expect(diff.removed).toBe("l"); + expect(diff.added).toBeFalsy(); + }); + it('remove at end of string', function() { + const diff = diffAtCaret("hello", "hell", 4); + expect(diff.at).toBe(4); + expect(diff.removed).toBe("o"); + expect(diff.added).toBeFalsy(); + }); + }); }); From 9bfba9db3ebd04556babba030d939d1835270908 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 13:33:42 +0200 Subject: [PATCH 03/15] make PartCreator a bit more testable by not asking for deps of dep --- .../views/elements/MessageEditor.js | 5 ++--- src/editor/parts.js | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index f51348ce04..a121f3384e 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -26,7 +26,7 @@ import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize'; import {findEditableEvent} from '../../../utils/EventUtils'; import {parseEvent} from '../../../editor/deserialize'; import Autocomplete from '../rooms/Autocomplete'; -import {PartCreator} from '../../../editor/parts'; +import {PartCreator, autoCompleteCreator} from '../../../editor/parts'; import {renderModel} from '../../../editor/render'; import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import {MatrixClient} from 'matrix-js-sdk'; @@ -303,8 +303,7 @@ export default class MessageEditor extends React.Component { const {editState} = this.props; const room = this._getRoom(); const partCreator = new PartCreator( - () => this._autocompleteRef, - query => this.setState({query}), + autoCompleteCreator(() => this._autocompleteRef, query => this.setState({query})), room, this.context.matrixClient, ); diff --git a/src/editor/parts.js b/src/editor/parts.js index 572a861024..971d93ef33 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -117,7 +117,8 @@ class BasePart { } } -class PlainPart extends BasePart { +// exported for unit tests, should otherwise only be used through PartCreator +export class PlainPart extends BasePart { acceptsInsertion(chr) { return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n"; } @@ -348,18 +349,24 @@ class PillCandidatePart extends PlainPart { } } -export class PartCreator { - constructor(getAutocompleterComponent, updateQuery, room, client) { - this._room = room; - this._client = client; - this._autoCompleteCreator = (updateCallback) => { +export function autoCompleteCreator(updateQuery, getAutocompleterComponent) { + return (partCreator) => { + return (updateCallback) => { return new AutocompleteWrapperModel( updateCallback, getAutocompleterComponent, updateQuery, - this, + partCreator, ); }; + }; +} + +export class PartCreator { + constructor(autoCompleteCreator, room, client) { + this._room = room; + this._client = client; + this._autoCompleteCreator = autoCompleteCreator(this); } createPartForInput(input) { From a7259b31b6598b5ba288d8c53b74f97b53c4f23d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 13:34:21 +0200 Subject: [PATCH 04/15] first set of model tests --- test/editor/model-test.js | 193 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 test/editor/model-test.js diff --git a/test/editor/model-test.js b/test/editor/model-test.js new file mode 100644 index 0000000000..cd435a79a9 --- /dev/null +++ b/test/editor/model-test.js @@ -0,0 +1,193 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import expect from 'expect'; +import EditorModel from "../../src/editor/model"; +import {PartCreator} from "../../src/editor/parts"; + +class MockAutoComplete { + constructor(updateCallback, partCreator, completions) { + this._updateCallback = updateCallback; + this._partCreator = partCreator; + this._completions = completions; + this._part = null; + } + + close() { + this._updateCallback({close: true}); + } + + tryComplete(close = true) { + const matches = this._completions.filter(o => { + return o.resourceId.startsWith(this._part.text); + }); + if (matches.length === 1 && this._part.text.length > 1) { + const match = matches[0]; + let pill; + if (match.resourceId[0] === "@") { + pill = this._partCreator.userPill(match.label, match.resourceId); + } else { + pill = this._partCreator.roomPill(match.resourceId); + } + this._updateCallback({replacePart: pill, close}); + } + } + + // called by EditorModel when typing into pill-candidate part + onPartUpdate(part, offset) { + this._part = part; + } +} + +// MockClient & MockRoom are only used for avatars in room and user pills, +// which is not tested +class MockClient { + getRooms() { return []; } + getRoom() { return null; } +} + +class MockRoom { + getMember() { return null; } +} + +function createPartCreator(completions = []) { + const autoCompleteCreator = (partCreator) => { + return (updateCallback) => new MockAutoComplete(updateCallback, partCreator, completions); + }; + return new PartCreator(autoCompleteCreator, new MockRoom(), new MockClient()); +} + +function createRenderer() { + const render = (c) => { + render.caret = c; + render.count += 1; + }; + render.count = 0; + render.caret = null; + return render; +} + +describe('editor/model', function() { + describe('plain text manipulation', function() { + it('insert text into empty document', function() { + const renderer = createRenderer(); + const model = new EditorModel([], createPartCreator(), renderer); + model.update("hello", "insertText", {offset: 5, atNodeEnd: true}); + expect(renderer.count).toBe(1); + expect(renderer.caret.index).toBe(0); + expect(renderer.caret.offset).toBe(5); + expect(model.parts.length).toBe(1); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello"); + }); + it('append text to existing document', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("hello")], pc, renderer); + model.update("hello world", "insertText", {offset: 11, atNodeEnd: true}); + expect(renderer.count).toBe(1); + expect(renderer.caret.index).toBe(0); + expect(renderer.caret.offset).toBe(11); + expect(model.parts.length).toBe(1); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello world"); + }); + it('prepend text to existing document', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("world")], pc, renderer); + model.update("hello world", "insertText", {offset: 6, atNodeEnd: false}); + expect(renderer.count).toBe(1); + expect(renderer.caret.index).toBe(0); + expect(renderer.caret.offset).toBe(6); + expect(model.parts.length).toBe(1); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello world"); + }); + it('insert new line into existing document', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("hello")], pc, renderer); + model.update("hello\n", "insertText", {offset: 6, atNodeEnd: true}); + expect(renderer.count).toBe(1); + expect(renderer.caret.index).toBe(1); + expect(renderer.caret.offset).toBe(1); + expect(model.parts.length).toBe(2); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello"); + expect(model.parts[1].type).toBe("newline"); + expect(model.parts[1].text).toBe("\n"); + }); + }); + describe('auto-complete', function() { + it('insert user pill', function() { + const renderer = createRenderer(); + const pc = createPartCreator([{resourceId: "@alice", label: "Alice"}]); + const model = new EditorModel([pc.plain("hello ")], pc, renderer); + + model.update("hello @a", "insertText", {offset: 8, atNodeEnd: true}); + + expect(renderer.count).toBe(1); + expect(renderer.caret.index).toBe(1); + expect(renderer.caret.offset).toBe(2); + expect(model.parts.length).toBe(2); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello "); + expect(model.parts[1].type).toBe("pill-candidate"); + expect(model.parts[1].text).toBe("@a"); + + model.autoComplete.tryComplete(); + + expect(renderer.count).toBe(2); + expect(renderer.caret.index).toBe(1); + expect(renderer.caret.offset).toBe(5); + expect(model.parts.length).toBe(2); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello "); + expect(model.parts[1].type).toBe("user-pill"); + expect(model.parts[1].text).toBe("Alice"); + }); + + it('insert room pill', function() { + const renderer = createRenderer(); + const pc = createPartCreator([{resourceId: "#riot-dev"}]); + const model = new EditorModel([pc.plain("hello ")], pc, renderer); + + model.update("hello #r", "insertText", {offset: 8, atNodeEnd: true}); + + expect(renderer.count).toBe(1); + expect(renderer.caret.index).toBe(1); + expect(renderer.caret.offset).toBe(2); + expect(model.parts.length).toBe(2); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello "); + expect(model.parts[1].type).toBe("pill-candidate"); + expect(model.parts[1].text).toBe("#r"); + + model.autoComplete.tryComplete(); + + expect(renderer.count).toBe(2); + expect(renderer.caret.index).toBe(1); + expect(renderer.caret.offset).toBe(9); + expect(model.parts.length).toBe(2); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello "); + expect(model.parts[1].type).toBe("room-pill"); + expect(model.parts[1].text).toBe("#riot-dev"); + }); + // paste circumvents AC + }); +}); From f8e1977f0a8ff6a7c2a0524662e70d12fbf4a5ab Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 13:34:30 +0200 Subject: [PATCH 05/15] remove dead code --- src/editor/serialize.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/editor/serialize.js b/src/editor/serialize.js index 37565b64a0..cb06eede6c 100644 --- a/src/editor/serialize.js +++ b/src/editor/serialize.js @@ -56,15 +56,3 @@ export function textSerialize(model) { } }, ""); } - -export function requiresHtml(model) { - return model.parts.some(part => { - switch (part.type) { - case "room-pill": - case "user-pill": - return true; - default: - return false; - } - }); -} From d6133eefd984af98169199e7d5fb089aefd1107c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 14:48:53 +0200 Subject: [PATCH 06/15] tests for non-editable parts --- test/editor/model-test.js | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/editor/model-test.js b/test/editor/model-test.js index cd435a79a9..780e9f8b8f 100644 --- a/test/editor/model-test.js +++ b/test/editor/model-test.js @@ -132,6 +132,44 @@ describe('editor/model', function() { expect(model.parts[1].text).toBe("\n"); }); }); + describe('non-editable part manipulation', function() { + it('typing at start of non-editable part prepends', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("try "), + pc.roomPill("#someroom"), + ], pc, renderer); + model.update("try foo#someroom", "insertText", {offset: 7, atNodeEnd: false}); + expect(renderer.caret.index).toBe(0); + expect(renderer.caret.offset).toBe(7); + expect(model.parts.length).toBe(2); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("try foo"); + expect(model.parts[1].type).toBe("room-pill"); + expect(model.parts[1].text).toBe("#someroom"); + }); + it('remove non-editable part with backspace', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([pc.roomPill("#someroom")], pc, renderer); + model.update("#someroo", "deleteContentBackward", {offset: 8, atNodeEnd: true}); + expect(renderer.count).toBe(1); + expect(renderer.caret.index).toBe(-1); + expect(renderer.caret.offset).toBe(0); + expect(model.parts.length).toBe(0); + }); + it('remove non-editable part with delete', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([pc.roomPill("#someroom")], pc, renderer); + model.update("someroom", "deleteContentForward", {offset: 0, atNodeEnd: false}); + expect(renderer.count).toBe(1); + expect(renderer.caret.index).toBe(-1); + expect(renderer.caret.offset).toBe(0); + expect(model.parts.length).toBe(0); + }); + }); describe('auto-complete', function() { it('insert user pill', function() { const renderer = createRenderer(); From a474f53c51484df23710216d9dbde1f2af5d95d4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 14:49:19 +0200 Subject: [PATCH 07/15] more auto-complete tests --- test/editor/model-test.js | 56 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/test/editor/model-test.js b/test/editor/model-test.js index 780e9f8b8f..594fb35c99 100644 --- a/test/editor/model-test.js +++ b/test/editor/model-test.js @@ -187,7 +187,7 @@ describe('editor/model', function() { expect(model.parts[1].type).toBe("pill-candidate"); expect(model.parts[1].text).toBe("@a"); - model.autoComplete.tryComplete(); + model.autoComplete.tryComplete(); // see MockAutoComplete expect(renderer.count).toBe(2); expect(renderer.caret.index).toBe(1); @@ -215,7 +215,7 @@ describe('editor/model', function() { expect(model.parts[1].type).toBe("pill-candidate"); expect(model.parts[1].text).toBe("#r"); - model.autoComplete.tryComplete(); + model.autoComplete.tryComplete(); // see MockAutoComplete expect(renderer.count).toBe(2); expect(renderer.caret.index).toBe(1); @@ -226,6 +226,56 @@ describe('editor/model', function() { expect(model.parts[1].type).toBe("room-pill"); expect(model.parts[1].text).toBe("#riot-dev"); }); - // paste circumvents AC + + it('type after inserting pill', function() { + const renderer = createRenderer(); + const pc = createPartCreator([{resourceId: "#riot-dev"}]); + const model = new EditorModel([pc.plain("hello ")], pc, renderer); + + model.update("hello #r", "insertText", {offset: 8, atNodeEnd: true}); + model.autoComplete.tryComplete(); // see MockAutoComplete + model.update("hello #riot-dev!!", "insertText", {offset: 17, atNodeEnd: true}); + + expect(renderer.count).toBe(3); + expect(renderer.caret.index).toBe(2); + expect(renderer.caret.offset).toBe(2); + expect(model.parts.length).toBe(3); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello "); + expect(model.parts[1].type).toBe("room-pill"); + expect(model.parts[1].text).toBe("#riot-dev"); + expect(model.parts[2].type).toBe("plain"); + expect(model.parts[2].text).toBe("!!"); + }); + + it('pasting text does not trigger auto-complete', function() { + const renderer = createRenderer(); + const pc = createPartCreator([{resourceId: "#define-room"}]); + const model = new EditorModel([pc.plain("try ")], pc, renderer); + + model.update("try #define", "insertFromPaste", {offset: 11, atNodeEnd: true}); + + expect(model.autoComplete).toBeFalsy(); + expect(renderer.caret.index).toBe(0); + expect(renderer.caret.offset).toBe(11); + expect(model.parts.length).toBe(1); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("try #define"); + }); + + it('dropping text does not trigger auto-complete', function() { + const renderer = createRenderer(); + const pc = createPartCreator([{resourceId: "#define-room"}]); + const model = new EditorModel([pc.plain("try ")], pc, renderer); + + model.update("try #define", "insertFromDrop", {offset: 11, atNodeEnd: true}); + + expect(model.autoComplete).toBeFalsy(); + expect(renderer.caret.index).toBe(0); + expect(renderer.caret.offset).toBe(11); + expect(model.parts.length).toBe(1); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("try #define"); + }); }); }); From 7f5ba08de989b7659764f9714071885679d233d7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 15:06:32 +0200 Subject: [PATCH 08/15] test typing in middle of pills --- test/editor/model-test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/editor/model-test.js b/test/editor/model-test.js index 594fb35c99..6052e6af9f 100644 --- a/test/editor/model-test.js +++ b/test/editor/model-test.js @@ -149,6 +149,25 @@ describe('editor/model', function() { expect(model.parts[1].type).toBe("room-pill"); expect(model.parts[1].text).toBe("#someroom"); }); + it('typing in middle of non-editable part appends', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("try "), + pc.roomPill("#someroom"), + pc.plain("?"), + ], pc, renderer); + model.update("try #some perhapsroom?", "insertText", {offset: 17, atNodeEnd: false}); + expect(renderer.caret.index).toBe(2); + expect(renderer.caret.offset).toBe(8); + expect(model.parts.length).toBe(3); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("try "); + expect(model.parts[1].type).toBe("room-pill"); + expect(model.parts[1].text).toBe("#someroom"); + expect(model.parts[2].type).toBe("plain"); + expect(model.parts[2].text).toBe(" perhaps?"); + }); it('remove non-editable part with backspace', function() { const renderer = createRenderer(); const pc = createPartCreator(); From 94957fcfd010c9abc0e89b4313ee67a7e8317e98 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 16:06:43 +0200 Subject: [PATCH 09/15] add more tests for empty lines --- test/editor/model-test.js | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/editor/model-test.js b/test/editor/model-test.js index 6052e6af9f..a8d891e280 100644 --- a/test/editor/model-test.js +++ b/test/editor/model-test.js @@ -117,6 +117,8 @@ describe('editor/model', function() { expect(model.parts[0].type).toBe("plain"); expect(model.parts[0].text).toBe("hello world"); }); + }); + describe('handling line breaks', function() { it('insert new line into existing document', function() { const renderer = createRenderer(); const pc = createPartCreator(); @@ -131,6 +133,52 @@ describe('editor/model', function() { expect(model.parts[1].type).toBe("newline"); expect(model.parts[1].text).toBe("\n"); }); + it('insert multiple new lines into existing document', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("hello")], pc, renderer); + model.update("hello\n\n\nworld!", "insertText", {offset: 14, atNodeEnd: true}); + expect(renderer.count).toBe(1); + expect(renderer.caret.index).toBe(4); + expect(renderer.caret.offset).toBe(6); + expect(model.parts.length).toBe(5); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello"); + expect(model.parts[1].type).toBe("newline"); + expect(model.parts[1].text).toBe("\n"); + expect(model.parts[2].type).toBe("newline"); + expect(model.parts[2].text).toBe("\n"); + expect(model.parts[3].type).toBe("newline"); + expect(model.parts[3].text).toBe("\n"); + expect(model.parts[4].type).toBe("plain"); + expect(model.parts[4].text).toBe("world!"); + }); + it('type in empty line', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello"), + pc.newline(), + pc.newline(), + pc.plain("world"), + ], pc, renderer); + model.update("hello\nwarm\nworld", "insertText", {offset: 10, atNodeEnd: true}); + console.log(model.serializeParts()); + expect(renderer.count).toBe(1); + expect(renderer.caret.index).toBe(2); + expect(renderer.caret.offset).toBe(4); + expect(model.parts.length).toBe(5); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello"); + expect(model.parts[1].type).toBe("newline"); + expect(model.parts[1].text).toBe("\n"); + expect(model.parts[2].type).toBe("plain"); + expect(model.parts[2].text).toBe("warm"); + expect(model.parts[3].type).toBe("newline"); + expect(model.parts[3].text).toBe("\n"); + expect(model.parts[4].type).toBe("plain"); + expect(model.parts[4].text).toBe("world"); + }); }); describe('non-editable part manipulation', function() { it('typing at start of non-editable part prepends', function() { From 08ff9e598ad4098a9065f093265bf2d9109650cd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 17:27:29 +0200 Subject: [PATCH 10/15] put mock code in separate file to reuse elsewhere --- test/editor/mock.js | 69 +++++++++++++++++++++++++++++++++++++++ test/editor/model-test.js | 54 +----------------------------- 2 files changed, 70 insertions(+), 53 deletions(-) create mode 100644 test/editor/mock.js diff --git a/test/editor/mock.js b/test/editor/mock.js new file mode 100644 index 0000000000..57ad0c52f3 --- /dev/null +++ b/test/editor/mock.js @@ -0,0 +1,69 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {PartCreator} from "../../src/editor/parts"; + +class MockAutoComplete { + constructor(updateCallback, partCreator, completions) { + this._updateCallback = updateCallback; + this._partCreator = partCreator; + this._completions = completions; + this._part = null; + } + + close() { + this._updateCallback({close: true}); + } + + tryComplete(close = true) { + const matches = this._completions.filter(o => { + return o.resourceId.startsWith(this._part.text); + }); + if (matches.length === 1 && this._part.text.length > 1) { + const match = matches[0]; + let pill; + if (match.resourceId[0] === "@") { + pill = this._partCreator.userPill(match.label, match.resourceId); + } else { + pill = this._partCreator.roomPill(match.resourceId); + } + this._updateCallback({replacePart: pill, close}); + } + } + + // called by EditorModel when typing into pill-candidate part + onPartUpdate(part, offset) { + this._part = part; + } +} + +// MockClient & MockRoom are only used for avatars in room and user pills, +// which is not tested +class MockClient { + getRooms() { return []; } + getRoom() { return null; } +} + +class MockRoom { + getMember() { return null; } +} + +export function createPartCreator(completions = []) { + const autoCompleteCreator = (partCreator) => { + return (updateCallback) => new MockAutoComplete(updateCallback, partCreator, completions); + }; + return new PartCreator(autoCompleteCreator, new MockRoom(), new MockClient()); +} diff --git a/test/editor/model-test.js b/test/editor/model-test.js index a8d891e280..c5f2a2ef12 100644 --- a/test/editor/model-test.js +++ b/test/editor/model-test.js @@ -16,59 +16,7 @@ limitations under the License. import expect from 'expect'; import EditorModel from "../../src/editor/model"; -import {PartCreator} from "../../src/editor/parts"; - -class MockAutoComplete { - constructor(updateCallback, partCreator, completions) { - this._updateCallback = updateCallback; - this._partCreator = partCreator; - this._completions = completions; - this._part = null; - } - - close() { - this._updateCallback({close: true}); - } - - tryComplete(close = true) { - const matches = this._completions.filter(o => { - return o.resourceId.startsWith(this._part.text); - }); - if (matches.length === 1 && this._part.text.length > 1) { - const match = matches[0]; - let pill; - if (match.resourceId[0] === "@") { - pill = this._partCreator.userPill(match.label, match.resourceId); - } else { - pill = this._partCreator.roomPill(match.resourceId); - } - this._updateCallback({replacePart: pill, close}); - } - } - - // called by EditorModel when typing into pill-candidate part - onPartUpdate(part, offset) { - this._part = part; - } -} - -// MockClient & MockRoom are only used for avatars in room and user pills, -// which is not tested -class MockClient { - getRooms() { return []; } - getRoom() { return null; } -} - -class MockRoom { - getMember() { return null; } -} - -function createPartCreator(completions = []) { - const autoCompleteCreator = (partCreator) => { - return (updateCallback) => new MockAutoComplete(updateCallback, partCreator, completions); - }; - return new PartCreator(autoCompleteCreator, new MockRoom(), new MockClient()); -} +import {createPartCreator} from "./mock"; function createRenderer() { const render = (c) => { From e8a31edeba41fd7e0af1291fe543915fc39583c5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 17:27:47 +0200 Subject: [PATCH 11/15] test html to editor model deserialization --- test/editor/deserialize-test.js | 226 ++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 test/editor/deserialize-test.js diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js new file mode 100644 index 0000000000..c7e0278f52 --- /dev/null +++ b/test/editor/deserialize-test.js @@ -0,0 +1,226 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import expect from 'expect'; +import {parseEvent} from "../../src/editor/deserialize"; +import {createPartCreator} from "./mock"; + +function htmlMessage(formattedBody, msgtype = "m.text") { + return { + getContent() { + return { + msgtype, + format: "org.matrix.custom.html", + formatted_body: formattedBody, + }; + }, + }; +} + +function textMessage(body, msgtype = "m.text") { + return { + getContent() { + return { + msgtype, + body, + }; + }, + }; +} + +function mergeAdjacentParts(parts) { + let prevPart; + for (let i = 0; i < parts.length; ++i) { + let part = parts[i]; + const isEmpty = !part.text.length; + const isMerged = !isEmpty && prevPart && prevPart.merge(part); + if (isEmpty || isMerged) { + // remove empty or merged part + part = prevPart; + parts.splice(i, 1); + //repeat this index, as it's removed now + --i; + } + prevPart = part; + } +} + +function normalize(parts) { + // merge adjacent parts as this will happen + // in the model anyway, and whether 1 or multiple + // plain parts are returned is an implementation detail + mergeAdjacentParts(parts); + // convert to data objects for easier asserting + return parts.map(p => p.serialize()); +} + +describe('editor/deserialize', function() { + describe('text messages', function() { + it('test with newlines', function() { + const parts = normalize(parseEvent(textMessage("hello\nworld"), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "hello"}); + expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "world"}); + }); + it('@room pill', function() { + const parts = normalize(parseEvent(textMessage("text message for @room"), createPartCreator())); + expect(parts.length).toBe(2); + expect(parts[0]).toStrictEqual({type: "plain", text: "text message for "}); + expect(parts[1]).toStrictEqual({type: "at-room-pill", text: "@room"}); + }); + it('emote', function() { + const text = "says DON'T SHOUT!"; + const parts = normalize(parseEvent(textMessage(text, "m.emote"), createPartCreator())); + expect(parts.length).toBe(1); + expect(parts[0]).toStrictEqual({type: "plain", text: "/me says DON'T SHOUT!"}); + }); + }); + describe('html messages', function() { + it('inline styling', function() { + const html = "bold and emphasized text"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(1); + expect(parts[0]).toStrictEqual({type: "plain", text: "**bold** and *emphasized* text"}); + }); + it('hyperlink', function() { + const html = 'click this!'; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(1); + expect(parts[0]).toStrictEqual({type: "plain", text: "click [this](http://example.com/)!"}); + }); + it('multiple lines with paragraphs', function() { + const html = '

hello

world

'; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "hello"}); + expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "world"}); + }); + it('multiple lines with line breaks', function() { + const html = 'hello
world'; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "hello"}); + expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "world"}); + }); + it('multiple lines mixing paragraphs and line breaks', function() { + const html = '

hello
warm

world

'; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(5); + expect(parts[0]).toStrictEqual({type: "plain", text: "hello"}); + expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "warm"}); + expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[4]).toStrictEqual({type: "plain", text: "world"}); + }); + it('quote', function() { + const html = '

wise
words

indeed

'; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(6); + expect(parts[0]).toStrictEqual({type: "plain", text: "> *wise*"}); + expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "> **words**"}); + expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[4]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[5]).toStrictEqual({type: "plain", text: "indeed"}); + }); + it('user pill', function() { + const html = "Hi Alice!"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); + expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice", userId: "@alice:hs.tld"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + }); + it('room pill', function() { + const html = "Try #room:hs.tld?"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "Try "}); + expect(parts[1]).toStrictEqual({type: "room-pill", text: "#room:hs.tld"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "?"}); + }); + it('@room pill', function() { + const html = "formatted message for @room"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(2); + expect(parts[0]).toStrictEqual({type: "plain", text: "*formatted* message for "}); + expect(parts[1]).toStrictEqual({type: "at-room-pill", text: "@room"}); + }); + it('inline code', function() { + const html = "there is no place like 127.0.0.1!"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(1); + expect(parts[0]).toStrictEqual({type: "plain", text: "there is no place like `127.0.0.1`!"}); + }); + it('code block with no trailing text', function() { + const html = "
0xDEADBEEF\n
\n"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + console.log(parts); + expect(parts.length).toBe(5); + expect(parts[0]).toStrictEqual({type: "plain", text: "```"}); + expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "0xDEADBEEF"}); + expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[4]).toStrictEqual({type: "plain", text: "```"}); + }); + // failing likely because of https://github.com/vector-im/riot-web/issues/10316 + xit('code block with no trailing text and no newlines', function() { + const html = "
0xDEADBEEF
"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(5); + expect(parts[0]).toStrictEqual({type: "plain", text: "```"}); + expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "0xDEADBEEF"}); + expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[4]).toStrictEqual({type: "plain", text: "```"}); + }); + it('unordered lists', function() { + const html = "
  • Oak
  • Spruce
  • Birch
"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(5); + expect(parts[0]).toStrictEqual({type: "plain", text: "- Oak"}); + expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "- Spruce"}); + expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[4]).toStrictEqual({type: "plain", text: "- Birch"}); + }); + it('ordered lists', function() { + const html = "
  1. Start
  2. Continue
  3. Finish
"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(5); + expect(parts[0]).toStrictEqual({type: "plain", text: "1. Start"}); + expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "1. Continue"}); + expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); + expect(parts[4]).toStrictEqual({type: "plain", text: "1. Finish"}); + }); + it('mx-reply is stripped', function() { + const html = "foobar"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(1); + expect(parts[0]).toStrictEqual({type: "plain", text: "bar"}); + }); + it('emote', function() { + const html = "says DON'T SHOUT!"; + const parts = normalize(parseEvent(htmlMessage(html, "m.emote"), createPartCreator())); + expect(parts.length).toBe(1); + expect(parts[0]).toStrictEqual({type: "plain", text: "/me says *DON'T SHOUT*!"}); + }); + }); +}); From 0b92077bba5a8dd3e2435bf2f6141e049b728f0d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 18:38:00 +0200 Subject: [PATCH 12/15] tests for turning caret position from model into dom position --- src/editor/caret.js | 2 +- test/editor/caret-test.js | 205 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 test/editor/caret-test.js diff --git a/src/editor/caret.js b/src/editor/caret.js index c56022d8c6..9b0fa14cfc 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -40,7 +40,7 @@ export function setCaretPosition(editor, model, caretPosition) { sel.addRange(range); } -function getLineAndNodePosition(model, caretPosition) { +export function getLineAndNodePosition(model, caretPosition) { const {parts} = model; const partIndex = caretPosition.index; const lineResult = findNodeInLineForPart(parts, partIndex); diff --git a/test/editor/caret-test.js b/test/editor/caret-test.js new file mode 100644 index 0000000000..9da28bff95 --- /dev/null +++ b/test/editor/caret-test.js @@ -0,0 +1,205 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import expect from 'expect'; +import {getLineAndNodePosition} from "../../src/editor/caret"; +import EditorModel from "../../src/editor/model"; +import {createPartCreator} from "./mock"; + +describe('editor/caret: DOM position for caret', function() { + describe('basic text handling', function() { + it('at end of single line', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 0, offset: 5}); + expect(lineIndex).toBe(0); + expect(nodeIndex).toBe(0); + expect(offset).toBe(5); + }); + it('at start of single line', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 0, offset: 0}); + expect(lineIndex).toBe(0); + expect(nodeIndex).toBe(0); + expect(offset).toBe(0); + }); + it('at middle of single line', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 0, offset: 2}); + expect(lineIndex).toBe(0); + expect(nodeIndex).toBe(0); + expect(offset).toBe(2); + }); + }); + describe('handling line breaks', function() { + it('at end of last line', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello"), + pc.newline(), + pc.plain("world"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 2, offset: 5}); + expect(lineIndex).toBe(1); + expect(nodeIndex).toBe(0); + expect(offset).toBe(5); + }); + it('at start of last line', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello"), + pc.newline(), + pc.plain("world"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 2, offset: 0}); + expect(lineIndex).toBe(1); + expect(nodeIndex).toBe(0); + expect(offset).toBe(0); + }); + it('in empty line', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello"), + pc.newline(), + pc.newline(), + pc.plain("world"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 1, offset: 1}); + expect(lineIndex).toBe(1); + expect(nodeIndex).toBe(-1); + expect(offset).toBe(0); + }); + it('after empty line', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello"), + pc.newline(), + pc.newline(), + pc.plain("world"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 3, offset: 0}); + expect(lineIndex).toBe(2); + expect(nodeIndex).toBe(0); + expect(offset).toBe(0); + }); + }); + describe('handling non-editable parts and caret nodes', function() { + it('at start of non-editable part (with plain text around)', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello"), + pc.userPill("Alice", "@alice:hs.tld"), + pc.plain("!"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 1, offset: 0}); + expect(lineIndex).toBe(0); + expect(nodeIndex).toBe(0); + expect(offset).toBe(5); + }); + it('in middle of non-editable part (with plain text around)', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello"), + pc.userPill("Alice", "@alice:hs.tld"), + pc.plain("!"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 1, offset: 2}); + expect(lineIndex).toBe(0); + expect(nodeIndex).toBe(2); + expect(offset).toBe(0); + }); + it('at start of non-editable part (without plain text around)', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.userPill("Alice", "@alice:hs.tld"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 0, offset: 0}); + expect(lineIndex).toBe(0); + //presumed nodes on line are (caret, pill, caret) + expect(nodeIndex).toBe(0); + expect(offset).toBe(0); + }); + it('in middle of non-editable part (without plain text around)', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.userPill("Alice", "@alice:hs.tld"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 0, offset: 1}); + expect(lineIndex).toBe(0); + //presumed nodes on line are (caret, pill, caret) + expect(nodeIndex).toBe(2); + expect(offset).toBe(0); + }); + it('in middle of a first non-editable part, with another one following', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.userPill("Alice", "@alice:hs.tld"), + pc.userPill("Bob", "@bob:hs.tld"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 0, offset: 1}); + expect(lineIndex).toBe(0); + //presumed nodes on line are (caret, pill, caret, pill, caret) + expect(nodeIndex).toBe(2); + expect(offset).toBe(0); + }); + it('in start of a second non-editable part, with another one before it', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.userPill("Alice", "@alice:hs.tld"), + pc.userPill("Bob", "@bob:hs.tld"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 1, offset: 0}); + expect(lineIndex).toBe(0); + //presumed nodes on line are (caret, pill, caret, pill, caret) + expect(nodeIndex).toBe(2); + expect(offset).toBe(0); + }); + it('in middle of a second non-editable part, with another one before it', function() { + const pc = createPartCreator(); + const model = new EditorModel([ + pc.userPill("Alice", "@alice:hs.tld"), + pc.userPill("Bob", "@bob:hs.tld"), + ]); + const {offset, lineIndex, nodeIndex} = + getLineAndNodePosition(model, {index: 1, offset: 1}); + expect(lineIndex).toBe(0); + //presumed nodes on line are (caret, pill, caret, pill, caret) + expect(nodeIndex).toBe(4); + expect(offset).toBe(0); + }); + }); +}); From b8a3c5ebd0710086cb9753ffd62b21b7864c0f33 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jul 2019 18:47:29 +0200 Subject: [PATCH 13/15] test serialization only produces html messages when needed --- test/editor/serialize-test.js | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/editor/serialize-test.js diff --git a/test/editor/serialize-test.js b/test/editor/serialize-test.js new file mode 100644 index 0000000000..2e7712e6e6 --- /dev/null +++ b/test/editor/serialize-test.js @@ -0,0 +1,47 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import expect from 'expect'; +import EditorModel from "../../src/editor/model"; +import {htmlSerializeIfNeeded} from "../../src/editor/serialize"; +import {createPartCreator} from "./mock"; + +describe('editor/serialize', function() { + it('user pill turns message into html', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")]); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Alice"); + }); + it('room pill turns message into html', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.roomPill("#room:hs.tld")]); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("#room:hs.tld"); + }); + it('@room pill turns message into html', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.atRoomPill("@room")]); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBeFalsy(); + }); + it('any markdown turns message into html', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("*hello* world")]); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("hello world"); + }); +}); From 4b08bf0e76106eb25c35b06afe9c81c2c2eb7e45 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 30 Jul 2019 12:31:40 +0200 Subject: [PATCH 14/15] add tests for removing multiple characters in edit --- test/editor/diff-test.js | 70 +++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/test/editor/diff-test.js b/test/editor/diff-test.js index 966e4db6cc..ebcb058baa 100644 --- a/test/editor/diff-test.js +++ b/test/editor/diff-test.js @@ -19,30 +19,54 @@ import {diffDeletion, diffAtCaret} from "../../src/editor/diff"; describe('editor/diff', function() { describe('diffDeletion', function() { - it('at start of string', function() { - const diff = diffDeletion("hello", "ello"); - expect(diff.at).toBe(0); - expect(diff.removed).toBe("h"); + describe('with a single character removed', function() { + it('at start of string', function() { + const diff = diffDeletion("hello", "ello"); + expect(diff.at).toBe(0); + expect(diff.removed).toBe("h"); + }); + it('in middle of string', function() { + const diff = diffDeletion("hello", "hllo"); + expect(diff.at).toBe(1); + expect(diff.removed).toBe("e"); + }); + it('in middle of string with duplicate character', function() { + const diff = diffDeletion("hello", "helo"); + expect(diff.at).toBe(3); + expect(diff.removed).toBe("l"); + }); + it('at end of string', function() { + const diff = diffDeletion("hello", "hell"); + expect(diff.at).toBe(4); + expect(diff.removed).toBe("o"); + }); }); - it('removing whole string', function() { - const diff = diffDeletion("hello", ""); - expect(diff.at).toBe(0); - expect(diff.removed).toBe("hello"); - }); - it('in middle of string', function() { - const diff = diffDeletion("hello", "hllo"); - expect(diff.at).toBe(1); - expect(diff.removed).toBe("e"); - }); - it('in middle of string with duplicate character', function() { - const diff = diffDeletion("hello", "helo"); - expect(diff.at).toBe(3); - expect(diff.removed).toBe("l"); - }); - it('at end of string', function() { - const diff = diffDeletion("hello", "hell"); - expect(diff.at).toBe(4); - expect(diff.removed).toBe("o"); + describe('with a multiple removed', function() { + it('at start of string', function() { + const diff = diffDeletion("hello", "llo"); + expect(diff.at).toBe(0); + expect(diff.removed).toBe("he"); + }); + it('removing whole string', function() { + const diff = diffDeletion("hello", ""); + expect(diff.at).toBe(0); + expect(diff.removed).toBe("hello"); + }); + it('in middle of string', function() { + const diff = diffDeletion("hello", "hlo"); + expect(diff.at).toBe(1); + expect(diff.removed).toBe("el"); + }); + it('in middle of string with duplicate character', function() { + const diff = diffDeletion("hello", "heo"); + expect(diff.at).toBe(2); + expect(diff.removed).toBe("ll"); + }); + it('at end of string', function() { + const diff = diffDeletion("hello", "hel"); + expect(diff.at).toBe(3); + expect(diff.removed).toBe("lo"); + }); }); }); describe('diffAtCaret', function() { From 44e466119882c4dcc798e6d85b8304f7770d3901 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 30 Jul 2019 12:34:30 +0200 Subject: [PATCH 15/15] document return type --- src/editor/diff.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/editor/diff.js b/src/editor/diff.js index b6839b1a88..27d10689b3 100644 --- a/src/editor/diff.js +++ b/src/editor/diff.js @@ -57,7 +57,9 @@ export function diffDeletion(oldStr, newStr) { * @param {String} oldValue the previous value * @param {String} newValue the new value * @param {Number} caretPosition the position of the caret after `newValue` was applied. - * @return {object} + * @return {object} an object with `at` as the offset where characters were removed and/or added, + * `added` with the added string (if any), and + * `removed` with the removed string (if any) */ export function diffAtCaret(oldValue, newValue, caretPosition) { const diffLen = newValue.length - oldValue.length;