Move more element-web tests over to vitest (#33845)
* Move more element-web tests over to vitest Switches default env to happy-dom as SdkConfig needs a DOM and is part of the setupTests given a lot of code relies on it * Restore jest variant of editor mock file for tests not yet moved over * Migrate batch of tests tro vitest * Iterate * Migrate another test * Simplify * Iterate * Iterate * Fix lockfile * Deduplicate * Iterate
This commit is contained in:
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { viewUserDeviceSettings } from "../../../../src/actions/handlers/viewUserDeviceSettings";
|
||||
import { UserTab } from "../../../../src/components/views/dialogs/UserTab";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
|
||||
describe("viewUserDeviceSettings()", () => {
|
||||
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
|
||||
beforeEach(() => {
|
||||
dispatchSpy.mockClear();
|
||||
});
|
||||
|
||||
it("dispatches action to view session manager", () => {
|
||||
viewUserDeviceSettings();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.SessionManager,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import withValidation from "../../../../src/components/views/elements/Validation";
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should handle 0 rules", () => {
|
||||
const handler = withValidation({
|
||||
rules: [],
|
||||
});
|
||||
return expect(
|
||||
handler({
|
||||
value: "value",
|
||||
focused: true,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
valid: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { translateLegacyEmojiData, mergeEmojiData } from "../../../../../src/emojipicker/recent.ts";
|
||||
|
||||
describe("recent", () => {
|
||||
describe("translateLegacyEmojiData", () => {
|
||||
it("should correctly translate to the new format", () => {
|
||||
expect(
|
||||
translateLegacyEmojiData([
|
||||
["🤩", 1],
|
||||
["😀", 2],
|
||||
]),
|
||||
).toEqual([
|
||||
{ emoji: "🤩", total: 1 },
|
||||
{ emoji: "😀", total: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeEmojiData", () => {
|
||||
it("should merge given data correctly", () => {
|
||||
expect(
|
||||
mergeEmojiData(
|
||||
[{ emoji: "🤩", total: 1 }],
|
||||
[
|
||||
{ emoji: "🤩", total: 2 },
|
||||
{ emoji: "😀", total: 1 },
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ emoji: "🤩", total: 2 },
|
||||
{ emoji: "😀", total: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle data2 being undefined", () => {
|
||||
expect(mergeEmojiData([{ emoji: "🤩", total: 1 }])).toEqual([{ emoji: "🤩", total: 1 }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
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")], pc);
|
||||
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")], pc);
|
||||
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")], pc);
|
||||
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 start of first line which is empty", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.newline(), pc.plain("hello world")], pc);
|
||||
const { offset, lineIndex, nodeIndex } = getLineAndNodePosition(model, { index: 0, offset: 0 });
|
||||
expect(lineIndex).toBe(0);
|
||||
expect(nodeIndex).toBe(-1);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
it("at end of last line", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello"), pc.newline(), pc.plain("world")], pc);
|
||||
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")], pc);
|
||||
const { offset, lineIndex, nodeIndex } = getLineAndNodePosition(model, { index: 2, offset: 0 });
|
||||
expect(lineIndex).toBe(1);
|
||||
expect(nodeIndex).toBe(0);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
it("before empty line", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello"), pc.newline(), pc.newline(), pc.plain("world")], pc);
|
||||
const { offset, lineIndex, nodeIndex } = getLineAndNodePosition(model, { index: 0, offset: 5 });
|
||||
expect(lineIndex).toBe(0);
|
||||
expect(nodeIndex).toBe(0);
|
||||
expect(offset).toBe(5);
|
||||
});
|
||||
it("in empty line", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello"), pc.newline(), pc.newline(), pc.plain("world")], pc);
|
||||
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")], pc);
|
||||
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("!")],
|
||||
pc,
|
||||
);
|
||||
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("!")],
|
||||
pc,
|
||||
);
|
||||
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")], pc);
|
||||
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")], pc);
|
||||
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")],
|
||||
pc,
|
||||
);
|
||||
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")],
|
||||
pc,
|
||||
);
|
||||
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")],
|
||||
pc,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import HistoryManager, { type IHistory, MAX_STEP_LENGTH } from "../../../src/editor/history";
|
||||
import type EditorModel from "../../../src/editor/model";
|
||||
import DocumentPosition from "../../../src/editor/position";
|
||||
|
||||
describe("editor/history", function () {
|
||||
it("push, then undo", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
const caret1 = new DocumentPosition(0, 0);
|
||||
const result1 = history.tryPush(model, caret1, "insertText", {});
|
||||
expect(result1).toEqual(true);
|
||||
parts[0] = "hello world";
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
expect(history.canUndo()).toEqual(true);
|
||||
const undoState = history.undo(model) as IHistory;
|
||||
expect(undoState.caret).toBe(caret1);
|
||||
expect(undoState.parts).toEqual(["hello"]);
|
||||
expect(history.canUndo()).toEqual(false);
|
||||
});
|
||||
it("push, undo, then redo", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
parts[0] = "hello world";
|
||||
const caret2 = new DocumentPosition(0, 0);
|
||||
history.tryPush(model, caret2, "insertText", {});
|
||||
history.undo(model);
|
||||
expect(history.canRedo()).toEqual(true);
|
||||
const redoState = history.redo() as IHistory;
|
||||
expect(redoState.caret).toBe(caret2);
|
||||
expect(redoState.parts).toEqual(["hello world"]);
|
||||
expect(history.canRedo()).toEqual(false);
|
||||
expect(history.canUndo()).toEqual(true);
|
||||
});
|
||||
it("push, undo, push, ensure you can`t redo", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
parts[0] = "hello world";
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
history.undo(model);
|
||||
parts[0] = "hello world!!";
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
expect(history.canRedo()).toEqual(false);
|
||||
});
|
||||
it("not every keystroke stores a history step", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
const firstCaret = new DocumentPosition(0, 0);
|
||||
history.tryPush(model, firstCaret, "insertText", {});
|
||||
const diff = { added: "o" };
|
||||
let keystrokeCount = 0;
|
||||
do {
|
||||
parts[0] = parts[0] + diff.added;
|
||||
keystrokeCount += 1;
|
||||
} while (!history.tryPush(model, new DocumentPosition(0, 0), "insertText", diff));
|
||||
const undoState = history.undo(model) as IHistory;
|
||||
expect(undoState.caret).toBe(firstCaret);
|
||||
expect(undoState.parts).toEqual(["hello"]);
|
||||
expect(history.canUndo()).toEqual(false);
|
||||
expect(keystrokeCount).toEqual(MAX_STEP_LENGTH + 1); // +1 before we type before checking
|
||||
});
|
||||
it("history step is added at word boundary", function () {
|
||||
const history = new HistoryManager();
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
const parts = ["h"];
|
||||
let diff = { added: "h" };
|
||||
const blankCaret = new DocumentPosition(0, 0);
|
||||
expect(history.tryPush(model, blankCaret, "insertText", diff)).toEqual(false);
|
||||
diff = { added: "i" };
|
||||
parts[0] = "hi";
|
||||
expect(history.tryPush(model, blankCaret, "insertText", diff)).toEqual(false);
|
||||
diff = { added: " " };
|
||||
parts[0] = "hi ";
|
||||
const spaceCaret = new DocumentPosition(1, 1);
|
||||
expect(history.tryPush(model, spaceCaret, "insertText", diff)).toEqual(true);
|
||||
diff = { added: "y" };
|
||||
parts[0] = "hi y";
|
||||
expect(history.tryPush(model, blankCaret, "insertText", diff)).toEqual(false);
|
||||
diff = { added: "o" };
|
||||
parts[0] = "hi yo";
|
||||
expect(history.tryPush(model, blankCaret, "insertText", diff)).toEqual(false);
|
||||
diff = { added: "u" };
|
||||
parts[0] = "hi you";
|
||||
|
||||
expect(history.canUndo()).toEqual(true);
|
||||
const undoResult = history.undo(model) as IHistory;
|
||||
expect(undoResult.caret).toEqual(spaceCaret);
|
||||
expect(undoResult.parts).toEqual(["hi "]);
|
||||
});
|
||||
it("keystroke that didn't add a step can undo", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
const firstCaret = new DocumentPosition(0, 0);
|
||||
history.tryPush(model, firstCaret, "insertText", {});
|
||||
parts[0] = "helloo";
|
||||
const result = history.tryPush(model, new DocumentPosition(0, 0), "insertText", { added: "o" });
|
||||
expect(result).toEqual(false);
|
||||
expect(history.canUndo()).toEqual(true);
|
||||
const undoState = history.undo(model) as IHistory;
|
||||
expect(undoState.caret).toEqual(firstCaret);
|
||||
expect(undoState.parts).toEqual(["hello"]);
|
||||
});
|
||||
it("undo after keystroke that didn't add a step is able to redo", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
parts[0] = "helloo";
|
||||
const caret = new DocumentPosition(1, 1);
|
||||
history.tryPush(model, caret, "insertText", { added: "o" });
|
||||
history.undo(model);
|
||||
expect(history.canRedo()).toEqual(true);
|
||||
const redoState = history.redo() as IHistory;
|
||||
expect(redoState.caret).toBe(caret);
|
||||
expect(redoState.parts).toEqual(["helloo"]);
|
||||
});
|
||||
|
||||
it("overwriting text always stores a step", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
const firstCaret = new DocumentPosition(0, 0);
|
||||
history.tryPush(model, firstCaret, "insertText", {});
|
||||
const diff = { at: 1, added: "a", removed: "e" };
|
||||
const secondCaret = new DocumentPosition(1, 1);
|
||||
const result = history.tryPush(model, secondCaret, "insertText", diff);
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type AutocompleteWrapperModel from "../../../src/editor/autocomplete";
|
||||
import { type Caret } from "../../../src/editor/caret";
|
||||
import { type PillPart, type Part, PartCreator } from "../../../src/editor/parts";
|
||||
import DocumentPosition from "../../../src/editor/position";
|
||||
import { vi } from "../../setup/adapter.ts";
|
||||
|
||||
export class MockAutoComplete {
|
||||
public _updateCallback;
|
||||
@@ -68,8 +69,8 @@ export function createPartCreator(completions: PillPart[] = []) {
|
||||
};
|
||||
const room = new MockRoom() as unknown as Room;
|
||||
const client = {
|
||||
getRooms: jest.fn().mockReturnValue([]),
|
||||
getRoom: jest.fn().mockReturnValue(null),
|
||||
getRooms: vi.fn().mockReturnValue([]),
|
||||
getRoom: vi.fn().mockReturnValue(null),
|
||||
} as unknown as MatrixClient;
|
||||
return new PartCreator(room, client, autoCompleteCreator);
|
||||
}
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { createPartCreator, createRenderer, type MockAutoComplete } from "./mock";
|
||||
import DocumentOffset from "../../../src/editor/offset";
|
||||
import { type PillPart } from "../../../src/editor/parts";
|
||||
import type DocumentPosition from "../../../src/editor/position";
|
||||
|
||||
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", new DocumentOffset(5, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(11, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(6, false));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(6, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(14, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(4);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(10, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(7, false));
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(17, false));
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(8, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(-1);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(0, false));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(-1);
|
||||
expect((renderer.caret as DocumentPosition).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", text: "Alice" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("hello ")], pc, renderer);
|
||||
|
||||
model.update("hello @a", "insertText", new DocumentOffset(8, true));
|
||||
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).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");
|
||||
|
||||
// this is a hacky mock function
|
||||
(model.autoComplete as unknown as MockAutoComplete).tryComplete();
|
||||
|
||||
expect(renderer.count).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).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" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("hello ")], pc, renderer);
|
||||
|
||||
model.update("hello #r", "insertText", new DocumentOffset(8, true));
|
||||
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).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");
|
||||
|
||||
// this is a hacky mock function
|
||||
(model.autoComplete as unknown as MockAutoComplete).tryComplete();
|
||||
|
||||
expect(renderer.count).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).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" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("hello ")], pc, renderer);
|
||||
|
||||
model.update("hello #r", "insertText", new DocumentOffset(8, true));
|
||||
// this is a hacky mock function
|
||||
(model.autoComplete as unknown as MockAutoComplete).tryComplete();
|
||||
model.update("hello #riot-dev!!", "insertText", new DocumentOffset(17, true));
|
||||
|
||||
expect(renderer.count).toBe(3);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).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" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("try ")], pc, renderer);
|
||||
|
||||
model.update("try #define", "insertFromPaste", new DocumentOffset(11, true));
|
||||
|
||||
expect(model.autoComplete).toBeFalsy();
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("try ")], pc, renderer);
|
||||
|
||||
model.update("try #define", "insertFromDrop", new DocumentOffset(11, true));
|
||||
|
||||
expect(model.autoComplete).toBeFalsy();
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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("insert room pill without splitting at the colon", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator([{ resourceId: "#room:server" } as PillPart]);
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
|
||||
model.update("#roo", "insertText", new DocumentOffset(4, true));
|
||||
|
||||
expect(renderer.count).toBe(1);
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("pill-candidate");
|
||||
expect(model.parts[0].text).toBe("#roo");
|
||||
|
||||
model.update("#room:s", "insertText", new DocumentOffset(7, true));
|
||||
|
||||
expect(renderer.count).toBe(2);
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("pill-candidate");
|
||||
expect(model.parts[0].text).toBe("#room:s");
|
||||
});
|
||||
|
||||
it("allow typing e-mail addresses without splitting at the @", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator([{ resourceId: "@alice", text: "Alice" } as PillPart]);
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
|
||||
model.update("foo@a", "insertText", new DocumentOffset(5, true));
|
||||
|
||||
expect(renderer.count).toBe(1);
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("plain");
|
||||
expect(model.parts[0].text).toBe("foo@a");
|
||||
});
|
||||
|
||||
it("should allow auto-completing multiple times with resets between them", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator([{ resourceId: "#riot-dev" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("")], pc, renderer);
|
||||
|
||||
model.update("#r", "insertText", new DocumentOffset(8, true));
|
||||
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).offset).toBe(2);
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("pill-candidate");
|
||||
expect(model.parts[0].text).toBe("#r");
|
||||
|
||||
// this is a hacky mock function
|
||||
(model.autoComplete as unknown as MockAutoComplete).tryComplete();
|
||||
|
||||
expect(renderer.count).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).offset).toBe(9);
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("room-pill");
|
||||
expect(model.parts[0].text).toBe("#riot-dev");
|
||||
|
||||
model.reset([]);
|
||||
model.update("#r", "insertText", new DocumentOffset(8, true));
|
||||
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("pill-candidate");
|
||||
expect(model.parts[0].text).toBe("#r");
|
||||
|
||||
// this is a hacky mock function
|
||||
(model.autoComplete as unknown as MockAutoComplete).tryComplete();
|
||||
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("room-pill");
|
||||
expect(model.parts[0].text).toBe("#riot-dev");
|
||||
});
|
||||
});
|
||||
describe("emojis", function () {
|
||||
it("regional emojis should be separated to prevent them to be converted to flag", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
const regionalEmojiA = String.fromCodePoint(127462);
|
||||
const regionalEmojiZ = String.fromCodePoint(127487);
|
||||
const caret = new DocumentOffset(0, true);
|
||||
|
||||
const regionalEmojis: string[] = [];
|
||||
regionalEmojis.push(regionalEmojiA);
|
||||
regionalEmojis.push(regionalEmojiZ);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
|
||||
model.transform(() => {
|
||||
const addedLen = model.insert(pc.plainWithEmoji(regionalEmojis[i]), position);
|
||||
caret.offset += addedLen;
|
||||
return model.positionForOffset(caret.offset, true);
|
||||
});
|
||||
}
|
||||
|
||||
expect(model.parts.length).toBeGreaterThanOrEqual(4);
|
||||
expect(model.parts[0].type).toBe("emoji");
|
||||
expect(model.parts[1].type).not.toBe("emoji");
|
||||
expect(model.parts[2].type).toBe("emoji");
|
||||
expect(model.parts[3].type).not.toBe("emoji");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,487 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { createPartCreator, createRenderer } from "./mock";
|
||||
import {
|
||||
formatRange,
|
||||
formatRangeAsCode,
|
||||
formatRangeAsLink,
|
||||
selectRangeOfWordAtCaret,
|
||||
toggleInlineFormat,
|
||||
} from "../../../src/editor/operations";
|
||||
import { Formatting } from "../../../src/components/views/rooms/MessageComposerFormatBar";
|
||||
import { longestBacktickSequence } from "../../../src/editor/deserialize";
|
||||
import type DocumentPosition from "../../../src/editor/position";
|
||||
|
||||
const SERIALIZED_NEWLINE = { text: "\n", type: "newline" };
|
||||
|
||||
describe("editor/operations: formatting operations", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
|
||||
describe("formatRange", () => {
|
||||
it.each([[Formatting.Bold, "hello **world**!"]])(
|
||||
"should correctly wrap format %s",
|
||||
(formatting: Formatting, expected: string) => {
|
||||
const model = new EditorModel([pc.plain("hello world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false), model.positionForOffset(11, false)); // around "world"
|
||||
|
||||
expect(range.parts[0].text).toBe("world");
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
formatRange(range, formatting);
|
||||
expect(model.serializeParts()).toEqual([{ text: expected, type: "plain" }]);
|
||||
},
|
||||
);
|
||||
|
||||
it("should apply to word range is within if length 0", () => {
|
||||
const model = new EditorModel([pc.plain("hello world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false));
|
||||
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
formatRange(range, Formatting.Bold);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello **world!**", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("should do nothing for a range with length 0 at initialisation", () => {
|
||||
const model = new EditorModel([pc.plain("hello world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false));
|
||||
range.setWasEmpty(false);
|
||||
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
formatRange(range, Formatting.Bold);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRangeAsLink", () => {
|
||||
it.each([
|
||||
// Caret is denoted by | in the expectation string
|
||||
["testing", "[testing](|)", ""],
|
||||
["testing", "[testing](foobar|)", "foobar"],
|
||||
["[testing]()", "testing|", ""],
|
||||
["[testing](foobar)", "testing|", ""],
|
||||
])("converts %s -> %s", (input: string, expectation: string, text: string) => {
|
||||
const model = new EditorModel([pc.plain(`foo ${input} bar`)], pc, renderer);
|
||||
|
||||
const range = model.startRange(
|
||||
model.positionForOffset(4, false),
|
||||
model.positionForOffset(4 + input.length, false),
|
||||
); // around input
|
||||
|
||||
expect(range.parts[0].text).toBe(input);
|
||||
formatRangeAsLink(range, text);
|
||||
expect((renderer.caret as DocumentPosition).offset).toBe(4 + expectation.indexOf("|"));
|
||||
expect(model.parts[0].text).toBe("foo " + expectation.replace("|", "") + " bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleInlineFormat", () => {
|
||||
it("works for words", () => {
|
||||
const model = new EditorModel([pc.plain("hello world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false), model.positionForOffset(11, false)); // around "world"
|
||||
|
||||
expect(range.parts[0].text).toBe("world");
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
formatRange(range, Formatting.Italics);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello *world*!", type: "plain" }]);
|
||||
});
|
||||
|
||||
describe("escape backticks", () => {
|
||||
it("works for escaping backticks in between texts", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello ` world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.positionForOffset(13, false)); // hello ` world
|
||||
|
||||
expect(range.parts[0].text.trim().includes("`")).toBeTruthy();
|
||||
expect(longestBacktickSequence(range.parts[0].text.trim())).toBe(1);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello ` world!", type: "plain" }]);
|
||||
formatRangeAsCode(range);
|
||||
expect(model.serializeParts()).toEqual([{ text: "``hello ` world``!", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("escapes longer backticks in between text", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello```world")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // hello```world
|
||||
|
||||
expect(range.parts[0].text.includes("`")).toBeTruthy();
|
||||
expect(longestBacktickSequence(range.parts[0].text)).toBe(3);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello```world", type: "plain" }]);
|
||||
formatRangeAsCode(range);
|
||||
expect(model.serializeParts()).toEqual([{ text: "````hello```world````", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("escapes non-consecutive with varying length backticks in between text", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hell```o`w`o``rld")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // hell```o`w`o``rld
|
||||
expect(range.parts[0].text.includes("`")).toBeTruthy();
|
||||
expect(longestBacktickSequence(range.parts[0].text)).toBe(3);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hell```o`w`o``rld", type: "plain" }]);
|
||||
formatRangeAsCode(range);
|
||||
expect(model.serializeParts()).toEqual([{ text: "````hell```o`w`o``rld````", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("untoggles correctly if its already formatted", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("```hello``world```")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // hello``world
|
||||
expect(range.parts[0].text.includes("`")).toBeTruthy();
|
||||
expect(longestBacktickSequence(range.parts[0].text)).toBe(3);
|
||||
expect(model.serializeParts()).toEqual([{ text: "```hello``world```", type: "plain" }]);
|
||||
formatRangeAsCode(range);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello``world", type: "plain" }]);
|
||||
});
|
||||
it("untoggles correctly it contains varying length of backticks between text", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("````hell```o`w`o``rld````")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // hell```o`w`o``rld
|
||||
expect(range.parts[0].text.includes("`")).toBeTruthy();
|
||||
expect(longestBacktickSequence(range.parts[0].text)).toBe(4);
|
||||
expect(model.serializeParts()).toEqual([{ text: "````hell```o`w`o``rld````", type: "plain" }]);
|
||||
formatRangeAsCode(range);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hell```o`w`o``rld", type: "plain" }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("works for parts of words", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(7, false), model.positionForOffset(10, false)); // around "orl"
|
||||
|
||||
expect(range.parts[0].text).toBe("orl");
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
toggleInlineFormat(range, "*");
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello w*orl*d!", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("works for around pills", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("hello there "), pc.atRoomPill("@room"), pc.plain(", how are you doing?")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false), model.positionForOffset(30, false)); // around "there @room, how are you"
|
||||
|
||||
expect(range.parts.map((p) => p.text).join("")).toBe("there @room, how are you");
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello there ", type: "plain" },
|
||||
{ text: "@room", type: "at-room-pill" },
|
||||
{ text: ", how are you doing?", type: "plain" },
|
||||
]);
|
||||
formatRange(range, Formatting.Italics);
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello *there ", type: "plain" },
|
||||
{ text: "@room", type: "at-room-pill" },
|
||||
{ text: ", how are you* doing?", type: "plain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("works for a paragraph", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("hello world,"), pc.newline(), pc.plain("how are you doing?")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false), model.positionForOffset(16, false)); // around "world,\nhow"
|
||||
|
||||
expect(range.parts.map((p) => p.text).join("")).toBe("world,\nhow");
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?", type: "plain" },
|
||||
]);
|
||||
formatRange(range, Formatting.Bold);
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello **world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how** are you doing?", type: "plain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("works for a paragraph with spurious breaks around it in selected range", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[
|
||||
pc.newline(),
|
||||
pc.newline(),
|
||||
pc.plain("hello world,"),
|
||||
pc.newline(),
|
||||
pc.plain("how are you doing?"),
|
||||
pc.newline(),
|
||||
pc.newline(),
|
||||
],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // select-all
|
||||
|
||||
expect(range.parts.map((p) => p.text).join("")).toBe("\n\nhello world,\nhow are you doing?\n\n");
|
||||
expect(model.serializeParts()).toEqual([
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
]);
|
||||
formatRange(range, Formatting.Bold);
|
||||
expect(model.serializeParts()).toEqual([
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "**hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?**", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
]);
|
||||
});
|
||||
|
||||
it("works for multiple paragraph", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[
|
||||
pc.plain("hello world,"),
|
||||
pc.newline(),
|
||||
pc.plain("how are you doing?"),
|
||||
pc.newline(),
|
||||
pc.newline(),
|
||||
pc.plain("new paragraph"),
|
||||
],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
let range = model.startRange(model.positionForOffset(0, true), model.getPositionAtEnd()); // select-all
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "new paragraph", type: "plain" },
|
||||
]);
|
||||
toggleInlineFormat(range, "__");
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "__hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?__", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "__new paragraph__", type: "plain" },
|
||||
]);
|
||||
range = model.startRange(model.positionForOffset(0, true), model.getPositionAtEnd()); // select-all
|
||||
toggleInlineFormat(range, "__");
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "new paragraph", type: "plain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("format word at caret position at beginning of new line without previous selection", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.newline(), pc.plain("hello!")], pc, renderer);
|
||||
|
||||
let range = model.startRange(model.positionForOffset(1, false));
|
||||
|
||||
// Initial position should equal start and end since we did not select anything
|
||||
expect(range.getLastStartingPosition()).toBe(range.start);
|
||||
expect(range.getLastStartingPosition()).toBe(range.end);
|
||||
|
||||
formatRange(range, Formatting.Bold); // Toggle
|
||||
|
||||
expect(model.serializeParts()).toEqual([SERIALIZED_NEWLINE, { text: "**hello!**", type: "plain" }]);
|
||||
|
||||
formatRange(range, Formatting.Bold); // Untoggle
|
||||
|
||||
expect(model.serializeParts()).toEqual([SERIALIZED_NEWLINE, { text: "hello!", type: "plain" }]);
|
||||
|
||||
// Check if it also works for code as it uses toggleInlineFormatting only indirectly
|
||||
range = model.startRange(model.positionForOffset(1, false));
|
||||
selectRangeOfWordAtCaret(range);
|
||||
|
||||
formatRange(range, Formatting.Code); // Toggle
|
||||
|
||||
expect(model.serializeParts()).toEqual([SERIALIZED_NEWLINE, { text: "`hello!`", type: "plain" }]);
|
||||
|
||||
formatRange(range, Formatting.Code); // Untoggle
|
||||
expect(model.serializeParts()).toEqual([SERIALIZED_NEWLINE, { text: "hello!", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("caret resets correctly to current line when untoggling formatting while caret at line end", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("hello **hello!**"), pc.newline(), pc.plain("world")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello **hello!**", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "world", type: "plain" },
|
||||
]);
|
||||
|
||||
const endOfFirstLine = 16;
|
||||
const range = model.startRange(model.positionForOffset(endOfFirstLine, true));
|
||||
|
||||
formatRange(range, Formatting.Bold); // Untoggle
|
||||
formatRange(range, Formatting.Italics); // Toggle
|
||||
|
||||
// We expect formatting to still happen in the first line as the caret should not jump down
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello *hello!*", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "world", type: "plain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("format link in front of new line part", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("hello!"), pc.newline(), pc.plain("world!"), pc.newline()],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
let range = model.startRange(model.getPositionAtEnd().asOffset(model).add(-1).asPosition(model)); // select-all
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello!", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "world!", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
]);
|
||||
|
||||
formatRange(range, Formatting.InsertLink); // Toggle
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello!", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "[world!]()", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
]);
|
||||
|
||||
range = model.startRange(model.getPositionAtEnd().asOffset(model).add(-1).asPosition(model)); // select-all
|
||||
formatRange(range, Formatting.InsertLink); // Untoggle
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello!", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "world!", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
]);
|
||||
});
|
||||
|
||||
it("format multi line code", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("int x = 1;"), pc.newline(), pc.newline(), pc.plain("int y = 42;")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
let range = model.startRange(model.positionForOffset(0), model.getPositionAtEnd()); // select-all
|
||||
|
||||
expect(range.parts.map((p) => p.text).join("")).toBe("int x = 1;\n\nint y = 42;");
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "int x = 1;", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "int y = 42;", type: "plain" },
|
||||
]);
|
||||
|
||||
formatRange(range, Formatting.Code); // Toggle
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "```", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "int x = 1;", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "int y = 42;", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "```", type: "plain" },
|
||||
]);
|
||||
|
||||
range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // select-all
|
||||
formatRange(range, Formatting.Code); // Untoggle
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "int x = 1;", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "int y = 42;", type: "plain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not format pure white space", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain(" "), pc.newline(), pc.newline(), pc.plain(" ")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0), model.getPositionAtEnd()); // select-all
|
||||
expect(range.parts.map((p) => p.text).join("")).toBe(" \n\n ");
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: " ", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: " ", type: "plain" },
|
||||
]);
|
||||
|
||||
formatRange(range, Formatting.Bold);
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: " ", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: " ", type: "plain" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EmojiPart, PlainPart } from "../../../src/editor/parts";
|
||||
import { createPartCreator } from "./mock";
|
||||
|
||||
describe("editor/parts", () => {
|
||||
describe("appendUntilRejected", () => {
|
||||
const femaleFacepalmEmoji = "🤦♀️";
|
||||
|
||||
it("should not accept emoji strings into type=plain", () => {
|
||||
const part = new PlainPart();
|
||||
expect(part.appendUntilRejected(femaleFacepalmEmoji, "")).toEqual(femaleFacepalmEmoji);
|
||||
expect(part.text).toEqual("");
|
||||
});
|
||||
|
||||
it("should accept emoji strings into type=emoji", () => {
|
||||
const part = new EmojiPart();
|
||||
expect(part.appendUntilRejected(femaleFacepalmEmoji, "")).toBeUndefined();
|
||||
expect(part.text).toEqual(femaleFacepalmEmoji);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not explode on room pills for unknown rooms", () => {
|
||||
const pc = createPartCreator();
|
||||
const part = pc.roomPill("#room:server");
|
||||
expect(() => part.toDOMNode()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { createPartCreator, createRenderer } from "./mock";
|
||||
|
||||
describe("editor/position", function () {
|
||||
it("move first position backward in empty model", function () {
|
||||
const model = new EditorModel([], createPartCreator(), createRenderer());
|
||||
const pos = model.positionForOffset(0, true);
|
||||
const pos2 = pos.backwardsWhile(model, () => true);
|
||||
expect(pos).toBe(pos2);
|
||||
});
|
||||
it("move first position forwards in empty model", function () {
|
||||
const model = new EditorModel([], createPartCreator(), createRenderer());
|
||||
const pos = model.positionForOffset(0, true);
|
||||
const pos2 = pos.forwardsWhile(model, () => true);
|
||||
expect(pos).toBe(pos2);
|
||||
});
|
||||
it("move forwards within one part", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello")], pc, createRenderer());
|
||||
const pos = model.positionForOffset(1);
|
||||
let n = 3;
|
||||
const pos2 = pos.forwardsWhile(model, () => {
|
||||
n -= 1;
|
||||
return n >= 0;
|
||||
});
|
||||
expect(pos2.index).toBe(0);
|
||||
expect(pos2.offset).toBe(4);
|
||||
});
|
||||
it("move forwards crossing to other part", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello"), pc.plain(" world")], pc, createRenderer());
|
||||
const pos = model.positionForOffset(4);
|
||||
let n = 3;
|
||||
const pos2 = pos.forwardsWhile(model, () => {
|
||||
n -= 1;
|
||||
return n >= 0;
|
||||
});
|
||||
expect(pos2.index).toBe(1);
|
||||
expect(pos2.offset).toBe(2);
|
||||
});
|
||||
it("move backwards within one part", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello")], pc, createRenderer());
|
||||
const pos = model.positionForOffset(4);
|
||||
let n = 3;
|
||||
const pos2 = pos.backwardsWhile(model, () => {
|
||||
n -= 1;
|
||||
return n >= 0;
|
||||
});
|
||||
expect(pos2.index).toBe(0);
|
||||
expect(pos2.offset).toBe(1);
|
||||
});
|
||||
it("move backwards crossing to other part", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello"), pc.plain(" world")], pc, createRenderer());
|
||||
const pos = model.positionForOffset(7);
|
||||
let n = 3;
|
||||
const pos2 = pos.backwardsWhile(model, () => {
|
||||
n -= 1;
|
||||
return n >= 0;
|
||||
});
|
||||
expect(pos2.index).toBe(0);
|
||||
expect(pos2.offset).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { createPartCreator, createRenderer } from "./mock";
|
||||
|
||||
const pillChannel = "#riot-dev:matrix.org";
|
||||
|
||||
describe("editor/range", function () {
|
||||
it("range on empty model", function () {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
const range = model.startRange(model.positionForOffset(0, true)); // after "world"
|
||||
let called = false;
|
||||
range.expandBackwardsWhile((chr) => {
|
||||
called = true;
|
||||
return true;
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
expect(range.text).toBe("");
|
||||
});
|
||||
it("range replace within a part", function () {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello world!!!!")], pc, renderer);
|
||||
const range = model.startRange(model.positionForOffset(11)); // after "world"
|
||||
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
|
||||
expect(range.text).toBe("world");
|
||||
range.replace([pc.roomPill(pillChannel)]);
|
||||
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(pillChannel);
|
||||
expect(model.parts[2].type).toBe("plain");
|
||||
expect(model.parts[2].text).toBe("!!!!");
|
||||
expect(model.parts.length).toBe(3);
|
||||
});
|
||||
it("range replace across parts", function () {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("try to re"), pc.plain("pla"), pc.plain("ce "), pc.plain("me")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
const range = model.startRange(model.positionForOffset(14)); // after "replace"
|
||||
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
|
||||
expect(range.text).toBe("replace");
|
||||
range.replace([pc.roomPill(pillChannel)]);
|
||||
expect(model.parts[0].type).toBe("plain");
|
||||
expect(model.parts[0].text).toBe("try to ");
|
||||
expect(model.parts[1].type).toBe("room-pill");
|
||||
expect(model.parts[1].text).toBe(pillChannel);
|
||||
expect(model.parts[2].type).toBe("plain");
|
||||
expect(model.parts[2].text).toBe(" me");
|
||||
expect(model.parts.length).toBe(3);
|
||||
});
|
||||
// bug found while implementing tab completion
|
||||
it("replace a part with an identical part with start position at end of previous part", function () {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello "), pc.pillCandidate("man")], pc, renderer);
|
||||
const range = model.startRange(model.positionForOffset(9, true)); // before "man"
|
||||
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
|
||||
expect(range.text).toBe("man");
|
||||
range.replace([pc.pillCandidate(range.text)]);
|
||||
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("man");
|
||||
expect(model.parts.length).toBe(2);
|
||||
});
|
||||
it("range trim spaces off both ends", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("abc abc abc")], pc, renderer);
|
||||
const range = model.startRange(
|
||||
model.positionForOffset(3, false), // at end of first `abc`
|
||||
model.positionForOffset(8, false), // at start of last `abc`
|
||||
);
|
||||
|
||||
expect(range.parts[0].text).toBe(" abc ");
|
||||
range.trim();
|
||||
expect(range.parts[0].text).toBe("abc");
|
||||
});
|
||||
// test for edge case when the selection just consists of whitespace
|
||||
it("range trim just whitespace", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const whitespace = " \n \n\n";
|
||||
const model = new EditorModel([pc.plain(whitespace)], pc, renderer);
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd());
|
||||
|
||||
expect(range.text).toBe(whitespace);
|
||||
range.trim();
|
||||
expect(range.text).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { parseEvent } from "../../../src/editor/deserialize";
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import DocumentOffset from "../../../src/editor/offset";
|
||||
import { htmlSerializeIfNeeded, textSerialize } from "../../../src/editor/serialize";
|
||||
import { createPartCreator } from "./mock";
|
||||
|
||||
function htmlMessage(formattedBody: string, msgtype = "m.text") {
|
||||
return {
|
||||
getContent() {
|
||||
return {
|
||||
msgtype,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: formattedBody,
|
||||
};
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
async function md2html(markdown: string): Promise<string> {
|
||||
const pc = createPartCreator();
|
||||
const oldModel = new EditorModel([], pc, () => {});
|
||||
await oldModel.update(markdown, "insertText", new DocumentOffset(markdown.length, false));
|
||||
return htmlSerializeIfNeeded(oldModel, { forceHTML: true })!;
|
||||
}
|
||||
|
||||
function html2md(html: string): string {
|
||||
const pc = createPartCreator();
|
||||
const parts = parseEvent(htmlMessage(html), pc);
|
||||
const newModel = new EditorModel(parts, pc);
|
||||
return textSerialize(newModel);
|
||||
}
|
||||
|
||||
async function roundTripMarkdown(markdown: string): Promise<string> {
|
||||
return html2md(await md2html(markdown));
|
||||
}
|
||||
|
||||
async function roundTripHtml(html: string): Promise<string> {
|
||||
return await md2html(html2md(html));
|
||||
}
|
||||
|
||||
describe("editor/roundtrip", function () {
|
||||
describe("markdown messages should round-trip if they contain", function () {
|
||||
test.each([
|
||||
["newlines", "hello\nworld"],
|
||||
["pills", "text message for @room"],
|
||||
["pills with interesting characters in mxid", "text message for @alice\\\\\\_\\]#>&:hs.example.com"],
|
||||
["styling", "**bold** and _emphasised_"],
|
||||
["bold within a word", "abso**fragging**lutely"],
|
||||
["escaped html", "a\\<foo>b"],
|
||||
["escaped markdown", "\\*\\*foo\\*\\* \\_bar\\_ \\[a\\](b)"],
|
||||
["escaped backslashes", "C:\\\\Program Files"],
|
||||
["code in backticks", "foo ->`x`"],
|
||||
["code blocks containing backticks", "```\nfoo ->`x`\nbar\n```"],
|
||||
["code blocks containing markdown", "```\n__init__.py\n```"],
|
||||
["nested formatting", "a<del>b **c _d_ e** f</del>g"],
|
||||
["an ordered list", "A\n\n1. b\n2. c\n3. d\nE"],
|
||||
["an ordered list starting later", "A\n\n9. b\n10. c\n11. d\nE"],
|
||||
["an unordered list", "A\n\n- b\n- c\n- d\nE"],
|
||||
["code block followed by text after a blank line", "```A\nfoo(bar).baz();\n\n3\n```\n\nB"],
|
||||
["just a code block", "```\nfoo(bar).baz();\n\n3\n```"],
|
||||
["code block with language specifier", "```bash\nmake install\n\n```"],
|
||||
["inline code", "there's no place `127.0.0.1` like"],
|
||||
["nested quotations", "saying\n\n> > foo\n\n> NO\n\nis valid"],
|
||||
["quotations", "saying\n\n> NO\n\nis valid"],
|
||||
["links", "click [this](http://example.com/)!"],
|
||||
])("%s", async (_name, markdown) => {
|
||||
expect(await roundTripMarkdown(markdown)).toEqual(markdown);
|
||||
});
|
||||
|
||||
test.skip.each([
|
||||
// Removes trailing spaces
|
||||
["a code block followed by newlines", "```\nfoo(bar).baz();\n\n3\n```\n\n"],
|
||||
// Adds a space after the code block
|
||||
["a code block surrounded by text", "```A\nfoo(bar).baz();\n\n3\n```\nB"],
|
||||
// Adds a space before the list
|
||||
["an unordered list directly preceded by text", "A\n- b\n- c\n- d\nE"],
|
||||
// Re-numbers to 1, 2, 3
|
||||
["an ordered list where everything is 1", "A\n\n1. b\n1. c\n1. d\nE"],
|
||||
// Adds a space before the list
|
||||
["an ordered list directly preceded by text", "A\n1. b\n2. c\n3. d\nE"],
|
||||
// Adds and removes spaces before the nested list
|
||||
["nested unordered lists", "A\n- b\n- c\n - c1\n - c2\n- d\nE"],
|
||||
// Adds and removes spaces before the nested list
|
||||
["nested ordered lists", "A\n\n1. b\n2. c\n 1. c1\n 2. c2\n3. d\nE"],
|
||||
// Adds and removes spaces before the nested list
|
||||
["nested mixed lists", "A\n\n1. b\n2. c\n - c1\n - c2\n3. d\nE"],
|
||||
// Backslashes get doubled
|
||||
["backslashes", "C:\\Program Files"],
|
||||
// Deletes the whitespace
|
||||
["newlines with trailing and leading whitespace", "hello \n world"],
|
||||
// Escapes the underscores
|
||||
["underscores within a word", "abso_fragging_lutely"],
|
||||
// Includes the trailing text into the quotation
|
||||
// https://github.com/vector-im/element-web/issues/22341
|
||||
["quotations without separating newlines", "saying\n> NO\nis valid"],
|
||||
// Removes trailing and leading whitespace
|
||||
["quotations with trailing and leading whitespace", "saying \n\n> NO\n\n is valid"],
|
||||
])("%s", async (_name, markdown) => {
|
||||
expect(await roundTripMarkdown(markdown)).toEqual(markdown);
|
||||
});
|
||||
|
||||
it("styling, but * becomes _ and __ becomes **", async function () {
|
||||
expect(await roundTripMarkdown("__bold__ and *emphasised*")).toEqual("**bold** and _emphasised_");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML messages should round-trip if they contain", function () {
|
||||
test.each([
|
||||
["backslashes", "C:\\Program Files"],
|
||||
[
|
||||
"nested blockquotes",
|
||||
"<blockquote>\n<p>foo</p>\n<blockquote>\n<p>bar</p>\n</blockquote>\n</blockquote>\n",
|
||||
],
|
||||
["ordered lists", "<ol>\n<li>asd</li>\n<li>fgd</li>\n</ol>\n"],
|
||||
["ordered lists starting later", '<ol start="3">\n<li>asd</li>\n<li>fgd</li>\n</ol>\n'],
|
||||
["unordered lists", "<ul>\n<li>asd</li>\n<li>fgd</li>\n</ul>\n"],
|
||||
["code blocks with surrounding text", "<p>a</p>\n<pre><code>a\ny;\n</code></pre>\n<p>b</p>\n"],
|
||||
["code blocks", "<pre><code>a\ny;\n</code></pre>\n"],
|
||||
["code blocks containing markdown", "<pre><code>__init__.py\n</code></pre>\n"],
|
||||
["code blocks with language specifier", '<pre><code class="language-bash">__init__.py\n</code></pre>\n'],
|
||||
["paragraphs including formatting", "<p>one</p>\n<p>t <em>w</em> o</p>\n"],
|
||||
["paragraphs", "<p>one</p>\n<p>two</p>\n"],
|
||||
["links", "http://more.example.com/"],
|
||||
["escaped html", "This >em<isn't>em< important"],
|
||||
["markdown-like symbols", "You _would_ **type** [a](http://this.example.com) this."],
|
||||
["formatting within a word", "abso<strong>fragging</strong>lutely"],
|
||||
["formatting", "This <em>is</em> im<strong>port</strong>ant"],
|
||||
["line breaks", "one<br>two"],
|
||||
])("%s", async (_name, html) => {
|
||||
expect(await roundTripHtml(html)).toEqual(html);
|
||||
});
|
||||
|
||||
test.skip.each([
|
||||
// Strips out the pill - maybe needs some user lookup to work?
|
||||
["user pills", '<a href="https://matrix.to/#/@alice:hs.tld">Alice</a>'],
|
||||
// Appends a slash to the URL
|
||||
// https://github.com/vector-im/element-web/issues/22342
|
||||
["links without trailing slashes", 'Go <a href="http://more.example.com">here</a> to see more'],
|
||||
// Inserts newlines after tags
|
||||
["paragraphs without newlines", "<p>one</p><p>two</p>"],
|
||||
// Inserts a code block
|
||||
["nested lists", "<ol>\n<li>asd</li>\n<li>\n<ul>\n<li>fgd</li>\n<li>sdf</li>\n</ul>\n</li>\n</ol>\n"],
|
||||
])("%s", async (_name, html) => {
|
||||
expect(await roundTripHtml(html)).toEqual(html);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { htmlSerializeFromMdIfNeeded, htmlSerializeIfNeeded } from "../../../src/editor/serialize";
|
||||
import { createPartCreator } from "./mock";
|
||||
import { type IConfigOptions } from "../../../src/IConfigOptions";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import SdkConfig from "../../../src/SdkConfig";
|
||||
|
||||
describe("editor/serialize", function () {
|
||||
describe("with markdown", function () {
|
||||
it("user pill turns message into html", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")], pc);
|
||||
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")], pc);
|
||||
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")], pc);
|
||||
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")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe("<em>hello</em> world");
|
||||
});
|
||||
it("displaynames ending in a backslash work", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Displayname\\", "@user:server")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@user:server">Displayname\\</a>');
|
||||
});
|
||||
it("displaynames containing an opening square bracket work", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Displayname[[", "@user:server")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@user:server">Displayname[[</a>');
|
||||
});
|
||||
it("displaynames containing a closing square bracket work", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Displayname]", "@user:server")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@user:server">Displayname]</a>');
|
||||
});
|
||||
it("displaynames containing a newline work", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Display\nname", "@user:server")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@user:server">Display<br>name</a>');
|
||||
});
|
||||
it("escaped markdown should not retain backslashes", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("\\*hello\\* world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe("*hello* world");
|
||||
});
|
||||
it("escaped markdown should not retain backslashes around other markdown", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("\\*hello\\* **world**")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe("*hello* <strong>world</strong>");
|
||||
});
|
||||
it("escaped markdown should convert HTML entities", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("\\*hello\\* world < hey world!")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe("*hello* world < hey world!");
|
||||
});
|
||||
|
||||
it("lists with a single empty item are not considered markdown", function () {
|
||||
const pc = createPartCreator();
|
||||
|
||||
const model1 = new EditorModel([pc.plain("-")], pc);
|
||||
const html1 = htmlSerializeIfNeeded(model1, {});
|
||||
expect(html1).toBe(undefined);
|
||||
|
||||
const model2 = new EditorModel([pc.plain("* ")], pc);
|
||||
const html2 = htmlSerializeIfNeeded(model2, {});
|
||||
expect(html2).toBe(undefined);
|
||||
|
||||
const model3 = new EditorModel([pc.plain("2021.")], pc);
|
||||
const html3 = htmlSerializeIfNeeded(model3, {});
|
||||
expect(html3).toBe(undefined);
|
||||
});
|
||||
|
||||
it("lists with a single non-empty item are still markdown", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("2021. foo")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<ol start="2021">\n<li>foo</li>\n</ol>\n');
|
||||
});
|
||||
describe("with permalink_prefix set", function () {
|
||||
const sdkConfigGet = SdkConfig.get;
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key: keyof IConfigOptions, altCaseName?: string) => {
|
||||
if (key === "permalink_prefix") {
|
||||
return "https://element.fs.tld";
|
||||
} else return sdkConfigGet(key, altCaseName);
|
||||
});
|
||||
});
|
||||
|
||||
it("user pill uses matrix.to", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@alice:hs.tld">Alice</a>');
|
||||
});
|
||||
it("room pill uses matrix.to", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.roomPill("#room:hs.tld")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/#room:hs.tld">#room:hs.tld</a>');
|
||||
});
|
||||
afterEach(() => {
|
||||
mocked(SdkConfig.get).mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with plaintext", function () {
|
||||
it("markdown remains plaintext", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("*hello* world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
|
||||
expect(html).toBe("*hello* world");
|
||||
});
|
||||
it("markdown should retain backslashes", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("\\*hello\\* world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
|
||||
expect(html).toBe("\\*hello\\* world");
|
||||
});
|
||||
it("markdown should convert HTML entities", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("\\*hello\\* world < hey world!")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
|
||||
expect(html).toBe("\\*hello\\* world < hey world!");
|
||||
});
|
||||
it("plaintext remains plaintext even when forcing html", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, { forceHTML: true, useMarkdown: false });
|
||||
expect(html).toBe("hello world");
|
||||
});
|
||||
it("should treat tags not in allowlist as plaintext", () => {
|
||||
const html = htmlSerializeFromMdIfNeeded("<b>test</b>", {});
|
||||
expect(html).toBeUndefined();
|
||||
});
|
||||
it("should treat tags not in allowlist as plaintext even if escaped", () => {
|
||||
const html = htmlSerializeFromMdIfNeeded("\\<b>test</b>", {});
|
||||
expect(html).toBe("<b>test</b>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature_latex_maths", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature === "feature_latex_maths");
|
||||
});
|
||||
|
||||
it("should support inline katex", () => {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello $\\xi$ world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toMatchInlineSnapshot(`"hello <span data-mx-maths="\\xi"><code>\\xi</code></span> world"`);
|
||||
});
|
||||
|
||||
it("should support block katex", () => {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello \n$$\\xi$$\n world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toMatchInlineSnapshot(`
|
||||
"<p>hello</p>
|
||||
<div data-mx-maths="\\xi"><code>\\xi</code></div>
|
||||
<p>world</p>
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not mangle code blocks", () => {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello\n```\n$\\xi$\n```\nworld")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toMatchInlineSnapshot(`
|
||||
"<p>hello</p>
|
||||
<pre><code>$\\xi$
|
||||
</code></pre>
|
||||
<p>world</p>
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { renderHook } from "jest-matrix-react";
|
||||
|
||||
import { useDebouncedCallback } from "../../../src/hooks/spotlight/useDebouncedCallback";
|
||||
|
||||
describe("useDebouncedCallback", () => {
|
||||
beforeAll(() => jest.useFakeTimers());
|
||||
afterAll(() => jest.useRealTimers());
|
||||
|
||||
function render(enabled: boolean, callback: (...params: any[]) => void, params: any[]) {
|
||||
return renderHook(({ enabled, callback, params }) => useDebouncedCallback(enabled, callback, params), {
|
||||
initialProps: {
|
||||
enabled,
|
||||
callback,
|
||||
params,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("should be able to handle empty parameters", async () => {
|
||||
// When
|
||||
const params: any[] = [];
|
||||
const callback = jest.fn();
|
||||
render(true, callback, params);
|
||||
jest.advanceTimersByTime(1);
|
||||
|
||||
// Then
|
||||
expect(callback).toHaveBeenCalledTimes(0);
|
||||
|
||||
// When
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
// Then
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call the callback with the parameters", async () => {
|
||||
// When
|
||||
const params = ["USER NAME"];
|
||||
const callback = jest.fn();
|
||||
render(true, callback, params);
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
// Then
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(...params);
|
||||
});
|
||||
|
||||
it("should call the callback with the parameters when parameters change during the timeout", async () => {
|
||||
// When
|
||||
const params = ["USER NAME"];
|
||||
const callback = jest.fn();
|
||||
const { rerender } = render(true, callback, []);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
rerender({ enabled: true, callback, params });
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
// Then
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(...params);
|
||||
});
|
||||
|
||||
it("should handle multiple parameters", async () => {
|
||||
// When
|
||||
const params = [4, 8, 15, 16, 23, 42];
|
||||
const callback = jest.fn();
|
||||
const { rerender } = render(true, callback, []);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
rerender({ enabled: true, callback, params });
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
// Then
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(...params);
|
||||
});
|
||||
|
||||
it("should debounce quick changes", async () => {
|
||||
// When
|
||||
const queries = [
|
||||
"U",
|
||||
"US",
|
||||
"USE",
|
||||
"USER",
|
||||
"USER ",
|
||||
"USER N",
|
||||
"USER NM",
|
||||
"USER NMA",
|
||||
"USER NM",
|
||||
"USER N",
|
||||
"USER NA",
|
||||
"USER NAM",
|
||||
"USER NAME",
|
||||
];
|
||||
const callback = jest.fn();
|
||||
|
||||
const { rerender } = render(true, callback, []);
|
||||
jest.advanceTimersByTime(1);
|
||||
|
||||
for (const query of queries) {
|
||||
rerender({ enabled: true, callback, params: [query] });
|
||||
jest.advanceTimersByTime(50);
|
||||
}
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
// Then
|
||||
const query = queries[queries.length - 1];
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(query);
|
||||
});
|
||||
|
||||
it("should not debounce slow changes", async () => {
|
||||
// When
|
||||
const queries = [
|
||||
"U",
|
||||
"US",
|
||||
"USE",
|
||||
"USER",
|
||||
"USER ",
|
||||
"USER N",
|
||||
"USER NM",
|
||||
"USER NMA",
|
||||
"USER NM",
|
||||
"USER N",
|
||||
"USER NA",
|
||||
"USER NAM",
|
||||
"USER NAME",
|
||||
];
|
||||
const callback = jest.fn();
|
||||
|
||||
const { rerender } = render(true, callback, []);
|
||||
jest.advanceTimersByTime(1);
|
||||
for (const query of queries) {
|
||||
rerender({ enabled: true, callback, params: [query] });
|
||||
jest.advanceTimersByTime(200);
|
||||
}
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
// Then
|
||||
const query = queries[queries.length - 1];
|
||||
expect(callback).toHaveBeenCalledTimes(queries.length);
|
||||
expect(callback).toHaveBeenCalledWith(query);
|
||||
});
|
||||
|
||||
it("should not call the callback if it’s disabled", async () => {
|
||||
// When
|
||||
const queries = [
|
||||
"U",
|
||||
"US",
|
||||
"USE",
|
||||
"USER",
|
||||
"USER ",
|
||||
"USER N",
|
||||
"USER NM",
|
||||
"USER NMA",
|
||||
"USER NM",
|
||||
"USER N",
|
||||
"USER NA",
|
||||
"USER NAM",
|
||||
"USER NAME",
|
||||
];
|
||||
const callback = jest.fn();
|
||||
|
||||
const { rerender } = render(false, callback, []);
|
||||
jest.advanceTimersByTime(1);
|
||||
for (const query of queries) {
|
||||
rerender({ enabled: false, callback, params: [query] });
|
||||
jest.advanceTimersByTime(200);
|
||||
}
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
// Then
|
||||
expect(callback).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { renderHook, type RenderHookResult } from "jest-matrix-react";
|
||||
|
||||
import { useLatestResult } from "../../../src/hooks/useLatestResult";
|
||||
|
||||
// All tests use fake timers throughout, comments will show the elapsed time in ms
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockSetter = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetter.mockClear();
|
||||
});
|
||||
|
||||
function simulateRequest(
|
||||
hookResult: RenderHookResult<ReturnType<typeof useLatestResult>, typeof useLatestResult>["result"],
|
||||
{ id, delayInMs, result }: { id: string; delayInMs: number; result: string },
|
||||
) {
|
||||
const [setQuery, setResult] = hookResult.current;
|
||||
setQuery(id);
|
||||
setTimeout(() => setResult(id, result), delayInMs);
|
||||
}
|
||||
|
||||
describe("renderhook tests", () => {
|
||||
it("should return a result", () => {
|
||||
const { result: hookResult } = renderHook(() => useLatestResult(mockSetter));
|
||||
|
||||
const query = { id: "query1", delayInMs: 100, result: "result1" };
|
||||
simulateRequest(hookResult, query);
|
||||
|
||||
// check we have made no calls to the setter
|
||||
expect(mockSetter).not.toHaveBeenCalled();
|
||||
|
||||
// advance timer until the timeout elapses, check we have called the setter
|
||||
jest.advanceTimersToNextTimer();
|
||||
expect(mockSetter).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetter).toHaveBeenLastCalledWith(query.result);
|
||||
});
|
||||
|
||||
it("should not let a slower response to an earlier query overwrite the result of a later query", () => {
|
||||
const { result: hookResult } = renderHook(() => useLatestResult(mockSetter));
|
||||
|
||||
const slowQuery = { id: "slowQuery", delayInMs: 500, result: "slowResult" };
|
||||
const fastQuery = { id: "fastQuery", delayInMs: 100, result: "fastResult" };
|
||||
|
||||
simulateRequest(hookResult, slowQuery);
|
||||
simulateRequest(hookResult, fastQuery);
|
||||
|
||||
// advance to fastQuery response, check the setter call
|
||||
jest.advanceTimersToNextTimer();
|
||||
expect(mockSetter).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetter).toHaveBeenLastCalledWith(fastQuery.result);
|
||||
|
||||
// advance time to slowQuery response, check the setter has _not_ been
|
||||
// called again and that the result is still from the fast query
|
||||
jest.advanceTimersToNextTimer();
|
||||
expect(mockSetter).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetter).toHaveBeenLastCalledWith(fastQuery.result);
|
||||
});
|
||||
|
||||
it("should return expected results when all response times similar", () => {
|
||||
const { result: hookResult } = renderHook(() => useLatestResult(mockSetter));
|
||||
|
||||
const commonDelayInMs = 180;
|
||||
const query1 = { id: "q1", delayInMs: commonDelayInMs, result: "r1" };
|
||||
const query2 = { id: "q2", delayInMs: commonDelayInMs, result: "r2" };
|
||||
const query3 = { id: "q3", delayInMs: commonDelayInMs, result: "r3" };
|
||||
|
||||
// ELAPSED: 0ms, no queries sent
|
||||
simulateRequest(hookResult, query1);
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
// ELAPSED: 100ms, query1 sent, no responses
|
||||
expect(mockSetter).not.toHaveBeenCalled();
|
||||
simulateRequest(hookResult, query2);
|
||||
jest.advanceTimersByTime(70);
|
||||
|
||||
// ELAPSED: 170ms, query1 and query2 sent, no responses
|
||||
expect(mockSetter).not.toHaveBeenCalled();
|
||||
simulateRequest(hookResult, query3);
|
||||
jest.advanceTimersByTime(70);
|
||||
|
||||
// ELAPSED: 240ms, all queries sent, responses for query1 and query2
|
||||
expect(mockSetter).not.toHaveBeenCalled();
|
||||
|
||||
// ELAPSED: 360ms, all queries sent, all queries have responses
|
||||
jest.advanceTimersByTime(120);
|
||||
expect(mockSetter).toHaveBeenLastCalledWith(query3.result);
|
||||
});
|
||||
|
||||
it("should prevent out of order results", () => {
|
||||
const { result: hookResult } = renderHook(() => useLatestResult(mockSetter));
|
||||
|
||||
const query1 = { id: "q1", delayInMs: 0, result: "r1" };
|
||||
const query2 = { id: "q2", delayInMs: 50, result: "r2" };
|
||||
const query3 = { id: "q3", delayInMs: 1, result: "r3" };
|
||||
|
||||
// ELAPSED: 0ms, no queries sent
|
||||
simulateRequest(hookResult, query1);
|
||||
jest.advanceTimersByTime(5);
|
||||
|
||||
// ELAPSED: 5ms, query1 sent, response from query1
|
||||
expect(mockSetter).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetter).toHaveBeenLastCalledWith(query1.result);
|
||||
simulateRequest(hookResult, query2);
|
||||
jest.advanceTimersByTime(5);
|
||||
|
||||
// ELAPSED: 10ms, query1 and query2 sent, response from query1
|
||||
simulateRequest(hookResult, query3);
|
||||
jest.advanceTimersByTime(5);
|
||||
|
||||
// ELAPSED: 15ms, all queries sent, responses from query1 and query3
|
||||
expect(mockSetter).toHaveBeenCalledTimes(2);
|
||||
expect(mockSetter).toHaveBeenLastCalledWith(query3.result);
|
||||
|
||||
// ELAPSED: 65ms, all queries sent, all queries have responses
|
||||
// so check that the result is still from query3, not query2
|
||||
jest.advanceTimersByTime(50);
|
||||
expect(mockSetter).toHaveBeenLastCalledWith(query3.result);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { renderHook, act } from "jest-matrix-react";
|
||||
|
||||
import UIStore, { UI_EVENTS } from "../../../src/stores/UIStore";
|
||||
import { useWindowWidth } from "../../../src/hooks/useWindowWidth";
|
||||
|
||||
describe("useWindowWidth", () => {
|
||||
beforeEach(() => {
|
||||
UIStore.instance.windowWidth = 768;
|
||||
});
|
||||
|
||||
it("should return the current width of window, according to UIStore", () => {
|
||||
const { result } = renderHook(() => useWindowWidth());
|
||||
|
||||
expect(result.current).toBe(768);
|
||||
});
|
||||
|
||||
it("should update the value when UIStore's value changes", () => {
|
||||
const { result } = renderHook(() => useWindowWidth());
|
||||
|
||||
act(() => {
|
||||
UIStore.instance.windowWidth = 1024;
|
||||
UIStore.instance.emit(UI_EVENTS.Resize);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(1024);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user