Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 104 additions & 1 deletion packages/framework/react/src/test/text/plainUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,114 @@

import { strict as assert } from "node:assert";

import { TextAsTree } from "@fluidframework/tree/internal";
import {
independentView,
TreeViewConfiguration,
type TreeViewAlpha,
} from "@fluidframework/tree/alpha";

import { createUndoRedo } from "../../undoRedo.js";
// Allow import of file being tested
// eslint-disable-next-line import-x/no-internal-modules
import { computeSync } from "../../text/plain/plainUtils.js";
import { applyTextEdit, applyTextOps, computeSync } from "../../text/plain/plainUtils.js";

describe("plainUtils", () => {
describe("applyTextOps", () => {
it("inserts text, leaving the selection start before the edit and extending the end past it", () => {
// "hello" with selection "el" (1-3); insert "XX" at index 2, inside the selection.
// start (before the insert) is unchanged; end (after it) shifts by the inserted length.
const result = applyTextOps("hello", { start: 1, end: 3 }, [
{ type: "retain", count: 2 },
{ type: "insert", text: "XX" },
{ type: "retain", count: 3 },
]);
assert.deepEqual(result, { value: "heXXllo", selection: { start: 1, end: 5 } });
});

it("removes text, clamping a selection start inside the removal and pulling back the end after it", () => {
// "hello" with selection "llo" (2-5); remove "ell" (1-4).
// start sits inside the removal and clamps to its start; end is past it and pulls back.
const result = applyTextOps("hello", { start: 2, end: 5 }, [
{ type: "retain", count: 1 },
{ type: "remove", count: 3 },
{ type: "retain", count: 1 },
]);
assert.deepEqual(result, { value: "ho", selection: { start: 1, end: 2 } });
});

it("treats op counts as code points, not UTF-16 units, for astral characters", () => {
// "😀" is one code point but two UTF-16 units. Retain it, then insert after.
const value = "😀x";
// Cursor was at end (3 UTF-16 units); the insert is before it, so it shifts by 1.
const result = applyTextOps(value, { start: value.length, end: value.length }, [
{ type: "retain", count: 1 },
{ type: "insert", text: "Y" },
{ type: "retain", count: 1 },
]);
assert.deepEqual(result, { value: "😀Yx", selection: { start: 4, end: 4 } });
});

it("appends the tail of the old value not covered by trailing ops", () => {
const result = applyTextOps("hello", { start: 0, end: 0 }, [
{ type: "retain", count: 2 },
]);
assert.deepEqual(result, { value: "hello", selection: { start: 0, end: 0 } });
});

it("clamps a stale selection that lands outside the new value", () => {
// Selection points past the end of oldValue; after a shrinking edit it must not exceed value.length.
const result = applyTextOps("hello", { start: 10, end: 10 }, [
{ type: "remove", count: 3 },
{ type: "retain", count: 2 },
]);
assert.deepEqual(result, { value: "lo", selection: { start: 2, end: 2 } });
});
});

describe("applyTextEdit", () => {
function createTextView(initial: string): TreeViewAlpha<typeof TextAsTree.Tree> {
const view = independentView(new TreeViewConfiguration({ schema: TextAsTree.Tree }));
view.initialize(TextAsTree.Tree.fromString(initial));
return view;
}

it("applies the edit directly to an unhydrated (non-branch) node", () => {
const root = TextAsTree.Tree.fromString("hello");
applyTextEdit(root, "hello world");
assert.equal(root.fullString(), "hello world");
});

it("wraps the edit in a transaction tagged with the given label when on a branch", () => {
const view = createTextView("hello");
const label = Symbol("editor");
const manager = createUndoRedo(view);

applyTextEdit(view.root, "hello world", label);
assert.equal(view.root.fullString(), "hello world");

// The edit was a single transaction tagged with `label`, so a label-scoped undo
// reverts the whole edit in one step.
assert(manager.canUndo(label));
manager.undo(label);
assert.equal(view.root.fullString(), "hello");
manager.dispose();
});

it("defaults the transaction label to the root node when none is given", () => {
const view = createTextView("hello");
const manager = createUndoRedo(view);

applyTextEdit(view.root, "hello world");

// With no explicit label, the edit is tagged with `root` itself.
assert(manager.canUndo(view.root));
manager.undo(view.root);
assert.equal(view.root.fullString(), "hello");
manager.dispose();
});
});

describe("computeSync", () => {
/**
* Calls computeSync, applies the returned ops to a copy of `existing`,
Expand Down
99 changes: 63 additions & 36 deletions packages/framework/react/src/test/text/textEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@
import { strict as assert } from "node:assert";

import { TextAsTree } from "@fluidframework/tree/internal";
import { render } from "@testing-library/react";
import { act, render, renderHook } from "@testing-library/react";
import globalJsdom from "global-jsdom";

import { toPropTreeNode } from "../../propNode.js";
import { PlainTextMainView } from "../../text/index.js";
// eslint-disable-next-line import-x/no-internal-modules -- the hook is not re-exported from text/index; import it directly for testing.
import { useTreeSynchronizedString } from "../../text/plain/useTreeSynchronizedString.js";
import type { UndoRedo } from "../../undoRedo.js";

/** Read the current value of the editor's `<textarea>`. */
function getTextareaValue(container: HTMLElement): string {
const textarea = container.querySelector("textarea");
assert.ok(textarea, "Textarea should be present");
return textarea.value;
}

describe("Plain TextArea view", () => {
let cleanup: () => void;
before(() => {
Expand Down Expand Up @@ -45,20 +54,18 @@ describe("Plain TextArea view", () => {
const content = <ViewComponent root={toPropTreeNode(text)} />;
const rendered = render(content, { reactStrictMode });

assert.match(rendered.baseElement.textContent ?? "", /Hello World/);
assert.equal(getTextareaValue(rendered.container), "Hello World");
});

it("invalidates view when tree is mutated", () => {
const text = TextAsTree.Tree.fromString("Hello");
const content = <ViewComponent root={toPropTreeNode(text)} />;
const rendered = render(content, { reactStrictMode });

// Mutate the tree by inserting text
text.insertAt(5, " World");
// Mutate the tree; the controlled textarea updates from the hook's synced text.
act(() => text.insertAt(5, " World"));

// Rerender and verify the view updates
rendered.rerender(content);
assert.match(rendered.baseElement.textContent ?? "", /Hello World/);
assert.equal(getTextareaValue(rendered.container), "Hello World");
});

it("invalidates view when text is removed", () => {
Expand All @@ -67,32 +74,23 @@ describe("Plain TextArea view", () => {
const rendered = render(content, { reactStrictMode });

// Mutate the tree by removing " World" (indices 5 to 11)
text.removeRange(5, 11);
act(() => text.removeRange(5, 11));

// Rerender and verify the view updates
rendered.rerender(content);
assert.match(rendered.baseElement.textContent ?? "", /Hello/);
assert(rendered.baseElement.textContent !== null);
assert.doesNotMatch(rendered.baseElement.textContent, /World/);
assert.equal(getTextareaValue(rendered.container), "Hello");
});

it("invalidates view when text is cleared and replaced", () => {
const text = TextAsTree.Tree.fromString("Original");
const content = <ViewComponent root={toPropTreeNode(text)} />;
const rendered = render(content, { reactStrictMode });

// Clear all text
const length = [...text.characters()].length;
text.removeRange(0, length);

// Insert new text
text.insertAt(0, "Replaced");
act(() => {
const length = [...text.characters()].length;
text.removeRange(0, length);
text.insertAt(0, "Replaced");
});

// Rerender and verify the view updates
rendered.rerender(content);
assert.match(rendered.baseElement.textContent ?? "", /Replaced/);
assert(rendered.baseElement.textContent !== null);
assert.doesNotMatch(rendered.baseElement.textContent, /Original/);
assert.equal(getTextareaValue(rendered.container), "Replaced");
});

// Tests for surrogate pair characters (emojis use 2 UTF-16 code units)
Expand All @@ -104,7 +102,7 @@ describe("Plain TextArea view", () => {
const content = <ViewComponent root={toPropTreeNode(text)} />;
const rendered = render(content, { reactStrictMode });

assert.match(rendered.baseElement.textContent ?? "", /Hello 😀 World/);
assert.equal(getTextareaValue(rendered.container), "Hello 😀 World");
});

it("inserts text after surrogate pair characters", () => {
Expand All @@ -113,10 +111,9 @@ describe("Plain TextArea view", () => {
const rendered = render(content, { reactStrictMode });

// Insert after the emoji (index 2 in character count: A, 😀, B)
text.insertAt(2, "X");
act(() => text.insertAt(2, "X"));

rendered.rerender(content);
assert.match(rendered.baseElement.textContent ?? "", /A😀XB/);
assert.equal(getTextareaValue(rendered.container), "A😀XB");
});

it("removes surrogate pair characters", () => {
Expand All @@ -125,12 +122,9 @@ describe("Plain TextArea view", () => {
const rendered = render(content, { reactStrictMode });

// Remove the emoji (index 1, length 1 in character count)
text.removeRange(1, 2);
act(() => text.removeRange(1, 2));

rendered.rerender(content);
assert.match(rendered.baseElement.textContent ?? "", /AB/);
assert(rendered.baseElement.textContent !== null);
assert.doesNotMatch(rendered.baseElement.textContent, /😀/);
assert.equal(getTextareaValue(rendered.container), "AB");
});

it("handles multiple surrogate pair characters", () => {
Expand All @@ -139,15 +133,48 @@ describe("Plain TextArea view", () => {
const rendered = render(content, { reactStrictMode });

// Insert between emojis
text.insertAt(2, "!");
act(() => text.insertAt(2, "!"));

rendered.rerender(content);
assert.match(rendered.baseElement.textContent ?? "", /👋🌍!🎉/);
assert.equal(getTextareaValue(rendered.container), "👋🌍!🎉");
});
});
}
});

// The hook is a one-way tree → string sync; exercise it directly via renderHook.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests should live in the test module that corresponds with the source code module. For now, that would be useTreeSynchronizedString.spec.ts. That said, it might make sense to merge that function into plainUtils.ts, in which case these would move to plainUtilts.test.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved tests into a new useTreeSynchronizedString.spec.ts files

describe("useTreeSynchronizedString", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect we should probably have many more test cases than this. The code has a number of branches and edge cases we should make sure we are covering. E.g. cases where the selection gets clamped, cases where the selection is reduced to 0 characters, etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added collapseSelectionOnReread (with unit tests), unit tests for applyTextOps cases, and hook tests.

it("returns the tree's current text", () => {
const text = TextAsTree.Tree.fromString("Hello");
const { result } = renderHook(() => useTreeSynchronizedString(text));

assert.equal(result.current.text, "Hello");
});

it("syncs character changes into the returned text", () => {
const text = TextAsTree.Tree.fromString("Hello");
const { result } = renderHook(() => useTreeSynchronizedString(text));

act(() => text.insertAt(5, " World"));
assert.equal(result.current.text, "Hello World");

act(() => text.removeRange(0, 6));
assert.equal(result.current.text, "World");
});

it("adjusts the tracked selection across edits", () => {
const text = TextAsTree.Tree.fromString("Hello");
// Caret after "Hello".
const { result } = renderHook(() =>
useTreeSynchronizedString(text, { start: 5, end: 5 }),
);

// Inserting before the caret shifts it right by the inserted length.
act(() => text.insertAt(0, "Oh "));
assert.equal(result.current.text, "Oh Hello");
assert.deepEqual(result.current.selection, { start: 8, end: 8 });
});
});

describe("toolbar", () => {
const mockLabel = Symbol("test");
const mockUndoRedo: UndoRedo = {
Expand Down
12 changes: 11 additions & 1 deletion packages/framework/react/src/text/plain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,14 @@ export {
MainView as PlainTextMainView,
type MainViewProps as PlainTextMainViewProps,
} from "./plainTextView.js";
export { syncTextToTree } from "./plainUtils.js";
export {
applyTextEdit,
applyTextOps,
type ApplyTextOpsResult,
syncTextToTree,
type TextSelection,
} from "./plainUtils.js";
export {
useTreeSynchronizedString,
type SynchronizedString,
} from "./useTreeSynchronizedString.js";
Loading
Loading