Merge pull request #3247 from matrix-org/bwindels/editortests

Unit tests for new editor
This commit is contained in:
Bruno Windels 2019-07-30 14:46:33 +00:00 committed by GitHub
commit e855a056c7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1018 additions and 44 deletions

View file

@ -26,7 +26,7 @@ import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize';
import {findEditableEvent} from '../../../utils/EventUtils'; import {findEditableEvent} from '../../../utils/EventUtils';
import {parseEvent} from '../../../editor/deserialize'; import {parseEvent} from '../../../editor/deserialize';
import Autocomplete from '../rooms/Autocomplete'; import Autocomplete from '../rooms/Autocomplete';
import {PartCreator} from '../../../editor/parts'; import {PartCreator, autoCompleteCreator} from '../../../editor/parts';
import {renderModel} from '../../../editor/render'; import {renderModel} from '../../../editor/render';
import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import {MatrixClient} from 'matrix-js-sdk'; import {MatrixClient} from 'matrix-js-sdk';
@ -303,8 +303,7 @@ export default class MessageEditor extends React.Component {
const {editState} = this.props; const {editState} = this.props;
const room = this._getRoom(); const room = this._getRoom();
const partCreator = new PartCreator( const partCreator = new PartCreator(
() => this._autocompleteRef, autoCompleteCreator(() => this._autocompleteRef, query => this.setState({query})),
query => this.setState({query}),
room, room,
this.context.matrixClient, this.context.matrixClient,
); );

View file

@ -40,7 +40,7 @@ export function setCaretPosition(editor, model, caretPosition) {
sel.addRange(range); sel.addRange(range);
} }
function getLineAndNodePosition(model, caretPosition) { export function getLineAndNodePosition(model, caretPosition) {
const {parts} = model; const {parts} = model;
const partIndex = caretPosition.index; const partIndex = caretPosition.index;
const lineResult = findNodeInLineForPart(parts, partIndex); const lineResult = findNodeInLineForPart(parts, partIndex);

View file

@ -25,16 +25,6 @@ function firstDiff(a, b) {
return compareLen; 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) { function diffStringsAtEnd(oldStr, newStr) {
const len = Math.min(oldStr.length, newStr.length); const len = Math.min(oldStr.length, newStr.length);
const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len); const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len);
@ -52,24 +42,25 @@ function diffStringsAtEnd(oldStr, newStr) {
} }
} }
// assumes only characters have been deleted at one location in the string, and none added
export function diffDeletion(oldStr, newStr) { export function diffDeletion(oldStr, newStr) {
if (oldStr === newStr) { if (oldStr === newStr) {
return {}; return {};
} }
const firstDiffIdx = firstDiff(oldStr, newStr); const firstDiffIdx = firstDiff(oldStr, newStr);
const lastDiffIdx = oldStr.length - lastDiff(oldStr, newStr) + 1; const amount = oldStr.length - newStr.length;
return {at: firstDiffIdx, removed: oldStr.substring(firstDiffIdx, lastDiffIdx)}; return {at: firstDiffIdx, removed: oldStr.substr(firstDiffIdx, amount)};
}
export function diffInsertion(oldStr, newStr) {
const diff = diffDeletion(newStr, oldStr);
if (diff.removed) {
return {at: diff.at, added: diff.removed};
} else {
return diff;
}
} }
/**
* 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} 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) { export function diffAtCaret(oldValue, newValue, caretPosition) {
const diffLen = newValue.length - oldValue.length; const diffLen = newValue.length - oldValue.length;
const caretPositionBeforeInput = caretPosition - diffLen; const caretPositionBeforeInput = caretPosition - diffLen;

View file

@ -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) { acceptsInsertion(chr) {
return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n"; return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n";
} }
@ -348,18 +349,24 @@ class PillCandidatePart extends PlainPart {
} }
} }
export class PartCreator { export function autoCompleteCreator(updateQuery, getAutocompleterComponent) {
constructor(getAutocompleterComponent, updateQuery, room, client) { return (partCreator) => {
this._room = room; return (updateCallback) => {
this._client = client;
this._autoCompleteCreator = (updateCallback) => {
return new AutocompleteWrapperModel( return new AutocompleteWrapperModel(
updateCallback, updateCallback,
getAutocompleterComponent, getAutocompleterComponent,
updateQuery, updateQuery,
this, partCreator,
); );
}; };
};
}
export class PartCreator {
constructor(autoCompleteCreator, room, client) {
this._room = room;
this._client = client;
this._autoCompleteCreator = autoCompleteCreator(this);
} }
createPartForInput(input) { createPartForInput(input) {

View file

@ -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;
}
});
}

205
test/editor/caret-test.js Normal file
View file

@ -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);
});
});
});

View file

@ -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 = "<strong>bold</strong> and <em>emphasized</em> 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 <a href="http://example.com/">this</a>!';
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 = '<p>hello</p><p>world</p>';
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<br>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 = '<p>hello<br>warm</p><p>world</p>';
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 = '<blockquote><p><em>wise</em><br><strong>words</strong></p></blockquote><p>indeed</p>';
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 <a href=\"https://matrix.to/#/@alice:hs.tld\">Alice</a>!";
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 <a href=\"https://matrix.to/#/#room:hs.tld\">#room:hs.tld</a>?";
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 = "<em>formatted</em> 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 <code>127.0.0.1</code>!";
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 = "<pre><code>0xDEADBEEF\n</code></pre>\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 = "<pre><code>0xDEADBEEF</code></pre>";
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 = "<ul><li>Oak</li><li>Spruce</li><li>Birch</li></ul>";
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 = "<ol><li>Start</li><li>Continue</li><li>Finish</li></ol>";
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 = "<mx-reply>foo</mx-reply>bar";
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 <em>DON'T SHOUT</em>!";
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*!"});
});
});
});

146
test/editor/diff-test.js Normal file
View file

@ -0,0 +1,146 @@
/*
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, diffAtCaret} from "../../src/editor/diff";
describe('editor/diff', function() {
describe('diffDeletion', function() {
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");
});
});
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() {
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();
});
});
});

69
test/editor/mock.js Normal file
View file

@ -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());
}

296
test/editor/model-test.js Normal file
View file

@ -0,0 +1,296 @@
/*
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 {createPartCreator} from "./mock";
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");
});
});
describe('handling line breaks', function() {
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");
});
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() {
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('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();
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();
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(); // see MockAutoComplete
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(); // see MockAutoComplete
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");
});
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");
});
});
});

View file

@ -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("<a href=\"https://matrix.to/#/@alice:hs.tld\">Alice</a>");
});
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("<a href=\"https://matrix.to/#/#room:hs.tld\">#room:hs.tld</a>");
});
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("<em>hello</em> world");
});
});