summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/tests/fractionalIndex.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/tests/fractionalIndex.test.ts')
-rw-r--r--packages/excalidraw/tests/fractionalIndex.test.ts807
1 files changed, 807 insertions, 0 deletions
diff --git a/packages/excalidraw/tests/fractionalIndex.test.ts b/packages/excalidraw/tests/fractionalIndex.test.ts
new file mode 100644
index 0000000..b57af01
--- /dev/null
+++ b/packages/excalidraw/tests/fractionalIndex.test.ts
@@ -0,0 +1,807 @@
+/* eslint-disable no-lone-blocks */
+import {
+ syncInvalidIndices,
+ syncMovedIndices,
+ validateFractionalIndices,
+} from "../fractionalIndex";
+import { API } from "./helpers/api";
+import { arrayToMap } from "../utils";
+import { InvalidFractionalIndexError } from "../errors";
+import type { ExcalidrawElement, FractionalIndex } from "../element/types";
+import { deepCopyElement } from "../element/newElement";
+import { generateKeyBetween } from "fractional-indexing";
+
+describe("sync invalid indices with array order", () => {
+ describe("should NOT sync empty array", () => {
+ testMovedIndicesSync({
+ elements: [],
+ movedElements: [],
+ expect: {
+ unchangedElements: [],
+ validInput: true,
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [],
+ expect: {
+ unchangedElements: [],
+ validInput: true,
+ },
+ });
+ });
+
+ describe("should NOT sync when index is well defined", () => {
+ testMovedIndicesSync({
+ elements: [{ id: "A", index: "a1" }],
+ movedElements: [],
+ expect: {
+ unchangedElements: ["A"],
+ validInput: true,
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [{ id: "A", index: "a1" }],
+ expect: {
+ unchangedElements: ["A"],
+ validInput: true,
+ },
+ });
+ });
+
+ describe("should NOT sync when indices are well defined", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a2" },
+ { id: "C", index: "a3" },
+ ],
+ movedElements: [],
+ expect: {
+ unchangedElements: ["A", "B", "C"],
+ validInput: true,
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a2" },
+ { id: "C", index: "a3" },
+ ],
+ expect: {
+ unchangedElements: ["A", "B", "C"],
+ validInput: true,
+ },
+ });
+ });
+
+ describe("should sync when fractional index is not defined", () => {
+ testMovedIndicesSync({
+ elements: [{ id: "A" }],
+ movedElements: ["A"],
+ expect: {
+ unchangedElements: [],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [{ id: "A" }],
+ expect: {
+ unchangedElements: [],
+ },
+ });
+ });
+
+ describe("should sync when fractional indices are duplicated", () => {
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a1" },
+ ],
+ expect: {
+ unchangedElements: ["A"],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a1" },
+ ],
+ expect: {
+ unchangedElements: ["A"],
+ },
+ });
+ });
+
+ describe("should sync when a fractional index is out of order", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a2" },
+ { id: "B", index: "a1" },
+ ],
+ movedElements: ["B"],
+ expect: {
+ unchangedElements: ["A"],
+ },
+ });
+
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a2" },
+ { id: "B", index: "a1" },
+ ],
+ movedElements: ["A"],
+ expect: {
+ unchangedElements: ["B"],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a2" },
+ { id: "B", index: "a1" },
+ ],
+ expect: {
+ unchangedElements: ["A"],
+ },
+ });
+ });
+
+ describe("should sync when fractional indices are out of order", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a3" },
+ { id: "B", index: "a2" },
+ { id: "C", index: "a1" },
+ ],
+ movedElements: ["B", "C"],
+ expect: {
+ unchangedElements: ["A"],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a3" },
+ { id: "B", index: "a2" },
+ { id: "C", index: "a1" },
+ ],
+ expect: {
+ unchangedElements: ["A"],
+ },
+ });
+ });
+
+ describe("should sync when incorrect fractional index is in between correct ones ", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a0" },
+ { id: "C", index: "a2" },
+ ],
+ movedElements: ["B"],
+ expect: {
+ unchangedElements: ["A", "C"],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a0" },
+ { id: "C", index: "a2" },
+ ],
+ expect: {
+ unchangedElements: ["A", "C"],
+ },
+ });
+ });
+
+ describe("should sync when incorrect fractional index is on top and duplicated below", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a2" },
+ { id: "C", index: "a1" },
+ ],
+ movedElements: ["C"],
+ expect: {
+ unchangedElements: ["A", "B"],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a2" },
+ { id: "C", index: "a1" },
+ ],
+ expect: {
+ unchangedElements: ["A", "B"],
+ },
+ });
+ });
+
+ describe("should sync when given a mix of duplicate / invalid indices", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a0" },
+ { id: "B", index: "a2" },
+ { id: "C", index: "a1" },
+ { id: "D", index: "a1" },
+ { id: "E", index: "a2" },
+ ],
+ movedElements: ["C", "D", "E"],
+ expect: {
+ unchangedElements: ["A", "B"],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a0" },
+ { id: "B", index: "a2" },
+ { id: "C", index: "a1" },
+ { id: "D", index: "a1" },
+ { id: "E", index: "a2" },
+ ],
+ expect: {
+ unchangedElements: ["A", "B"],
+ },
+ });
+ });
+
+ describe("should sync when given a mix of undefined / invalid indices", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A" },
+ { id: "B" },
+ { id: "C", index: "a0" },
+ { id: "D", index: "a2" },
+ { id: "E" },
+ { id: "F", index: "a3" },
+ { id: "G" },
+ { id: "H", index: "a1" },
+ { id: "I", index: "a2" },
+ { id: "J" },
+ ],
+ movedElements: ["A", "B", "E", "G", "H", "I", "J"],
+ expect: {
+ unchangedElements: ["C", "D", "F"],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A" },
+ { id: "B" },
+ { id: "C", index: "a0" },
+ { id: "D", index: "a2" },
+ { id: "E" },
+ { id: "F", index: "a3" },
+ { id: "G" },
+ { id: "H", index: "a1" },
+ { id: "I", index: "a2" },
+ { id: "J" },
+ ],
+ expect: {
+ unchangedElements: ["C", "D", "F"],
+ },
+ });
+ });
+
+ describe("should sync all moved elements regardless of their validity", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a2" },
+ { id: "B", index: "a4" },
+ ],
+ movedElements: ["A"],
+ expect: {
+ validInput: true,
+ unchangedElements: ["B"],
+ },
+ });
+
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a2" },
+ { id: "B", index: "a4" },
+ ],
+ movedElements: ["B"],
+ expect: {
+ validInput: true,
+ unchangedElements: ["A"],
+ },
+ });
+
+ testMovedIndicesSync({
+ elements: [
+ { id: "C", index: "a2" },
+ { id: "D", index: "a3" },
+ { id: "A", index: "a0" },
+ { id: "B", index: "a1" },
+ ],
+ movedElements: ["C", "D"],
+ expect: {
+ unchangedElements: ["A", "B"],
+ },
+ });
+
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a2" },
+ { id: "D", index: "a4" },
+ { id: "C", index: "a3" },
+ { id: "F", index: "a6" },
+ { id: "E", index: "a5" },
+ { id: "H", index: "a8" },
+ { id: "G", index: "a7" },
+ { id: "I", index: "a9" },
+ ],
+ movedElements: ["D", "F", "H"],
+ expect: {
+ unchangedElements: ["A", "B", "C", "E", "G", "I"],
+ },
+ });
+
+ {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a0" },
+ { id: "C", index: "a2" },
+ ],
+ movedElements: ["B", "C"],
+ expect: {
+ unchangedElements: ["A"],
+ },
+ });
+
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a0" },
+ { id: "C", index: "a2" },
+ ],
+ movedElements: ["A", "B"],
+ expect: {
+ unchangedElements: ["C"],
+ },
+ });
+ }
+
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a0" },
+ { id: "B", index: "a2" },
+ { id: "C", index: "a1" },
+ { id: "D", index: "a1" },
+ { id: "E", index: "a2" },
+ ],
+ movedElements: ["B", "D", "E"],
+ expect: {
+ unchangedElements: ["A", "C"],
+ },
+ });
+
+ testMovedIndicesSync({
+ elements: [
+ { id: "A" },
+ { id: "B" },
+ { id: "C", index: "a0" },
+ { id: "D", index: "a2" },
+ { id: "E" },
+ { id: "F", index: "a3" },
+ { id: "G" },
+ { id: "H", index: "a1" },
+ { id: "I", index: "a2" },
+ { id: "J" },
+ ],
+ movedElements: ["A", "B", "D", "E", "F", "G", "J"],
+ expect: {
+ unchangedElements: ["C", "H", "I"],
+ },
+ });
+ });
+
+ describe("should generate fractions for explicitly moved elements", () => {
+ describe("should generate a fraction between 'A' and 'C'", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ // doing actual fractions, without jitter 'a1' becomes 'a1V'
+ // as V is taken as the charset's middle-right value
+ { id: "B", index: "a1" },
+ { id: "C", index: "a2" },
+ ],
+ movedElements: ["B"],
+ expect: {
+ unchangedElements: ["A", "C"],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a1" },
+ { id: "C", index: "a2" },
+ ],
+ expect: {
+ // as above, B will become fractional
+ unchangedElements: ["A", "C"],
+ },
+ });
+ });
+
+ describe("should generate fractions given duplicated indices", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a01" },
+ { id: "B", index: "a01" },
+ { id: "C", index: "a01" },
+ { id: "D", index: "a01" },
+ { id: "E", index: "a02" },
+ { id: "F", index: "a02" },
+ { id: "G", index: "a02" },
+ ],
+ movedElements: ["B", "C", "D", "E", "F"],
+ expect: {
+ unchangedElements: ["A", "G"],
+ },
+ });
+
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a01" },
+ { id: "B", index: "a01" },
+ { id: "C", index: "a01" },
+ { id: "D", index: "a01" },
+ { id: "E", index: "a02" },
+ { id: "F", index: "a02" },
+ { id: "G", index: "a02" },
+ ],
+ movedElements: ["A", "C", "D", "E", "G"],
+ expect: {
+ unchangedElements: ["B", "F"],
+ },
+ });
+
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a01" },
+ { id: "B", index: "a01" },
+ { id: "C", index: "a01" },
+ { id: "D", index: "a01" },
+ { id: "E", index: "a02" },
+ { id: "F", index: "a02" },
+ { id: "G", index: "a02" },
+ ],
+ movedElements: ["B", "C", "D", "F", "G"],
+ expect: {
+ unchangedElements: ["A", "E"],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a01" },
+ { id: "B", index: "a01" },
+ { id: "C", index: "a01" },
+ { id: "D", index: "a01" },
+ { id: "E", index: "a02" },
+ { id: "F", index: "a02" },
+ { id: "G", index: "a02" },
+ ],
+ expect: {
+ // notice fallback considers first item (E) as a valid one
+ unchangedElements: ["A", "E"],
+ },
+ });
+ });
+ });
+
+ describe("should be able to sync 20K invalid indices", () => {
+ const length = 20_000;
+
+ describe("should sync all empty indices", () => {
+ const elements = Array.from({ length }).map((_, index) => ({
+ id: `A_${index}`,
+ }));
+
+ testMovedIndicesSync({
+ // elements without fractional index
+ elements,
+ movedElements: Array.from({ length }).map((_, index) => `A_${index}`),
+ expect: {
+ unchangedElements: [],
+ },
+ });
+
+ testInvalidIndicesSync({
+ // elements without fractional index
+ elements,
+ expect: {
+ unchangedElements: [],
+ },
+ });
+ });
+
+ describe("should sync all but last index given a growing array of indices", () => {
+ let lastIndex: string | null = null;
+
+ const elements = Array.from({ length }).map((_, index) => {
+ // going up from 'a0'
+ lastIndex = generateKeyBetween(lastIndex, null);
+
+ return {
+ id: `A_${index}`,
+ // assigning the last generated index, so sync can go down from there
+ // without jitter lastIndex is 'c4BZ' for 20000th element
+ index: index === length - 1 ? lastIndex : undefined,
+ };
+ });
+ const movedElements = Array.from({ length }).map(
+ (_, index) => `A_${index}`,
+ );
+ // remove last element
+ movedElements.pop();
+
+ testMovedIndicesSync({
+ elements,
+ movedElements,
+ expect: {
+ unchangedElements: [`A_${length - 1}`],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements,
+ expect: {
+ unchangedElements: [`A_${length - 1}`],
+ },
+ });
+ });
+
+ describe("should sync all but first index given a declining array of indices", () => {
+ let lastIndex: string | null = null;
+
+ const elements = Array.from({ length }).map((_, index) => {
+ // going down from 'a0'
+ lastIndex = generateKeyBetween(null, lastIndex);
+
+ return {
+ id: `A_${index}`,
+ // without jitter lastIndex is 'XvoR' for 20000th element
+ index: lastIndex,
+ };
+ });
+ const movedElements = Array.from({ length }).map(
+ (_, index) => `A_${index}`,
+ );
+ // remove first element
+ movedElements.shift();
+
+ testMovedIndicesSync({
+ elements,
+ movedElements,
+ expect: {
+ unchangedElements: [`A_0`],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements,
+ expect: {
+ unchangedElements: [`A_0`],
+ },
+ });
+ });
+ });
+
+ describe("should automatically fallback to fixing all invalid indices", () => {
+ describe("should fallback to syncing duplicated indices when moved elements are empty", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a1" },
+ { id: "C", index: "a1" },
+ ],
+ // the validation will throw as nothing was synced
+ // therefore it will lead to triggering the fallback and fixing all invalid indices
+ movedElements: [],
+ expect: {
+ unchangedElements: ["A"],
+ },
+ });
+ });
+
+ describe("should fallback to syncing undefined / invalid indices when moved elements are empty", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B" },
+ { id: "C", index: "a0" },
+ ],
+ // since elements are invalid, this will fail the validation
+ // leading to fallback fixing "B" and "C"
+ movedElements: [],
+ expect: {
+ unchangedElements: ["A"],
+ },
+ });
+ });
+
+ describe("should fallback to syncing unordered indices when moved element is invalid", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a2" },
+ { id: "C", index: "a1" },
+ ],
+ movedElements: ["A"],
+ expect: {
+ unchangedElements: ["A", "B"],
+ },
+ });
+ });
+
+ describe("should fallback when trying to generate an index in between unordered elements", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a2" },
+ { id: "B" },
+ { id: "C", index: "a1" },
+ ],
+ // 'B' is invalid, but so is 'C', which was not marked as moved
+ // therefore it will try to generate a key between 'a2' and 'a1'
+ // which it cannot do, thus will throw during generation and automatically fallback
+ movedElements: ["B"],
+ expect: {
+ unchangedElements: ["A"],
+ },
+ });
+ });
+
+ describe("should fallback when trying to generate an index in between duplicate indices", () => {
+ testMovedIndicesSync({
+ elements: [
+ { id: "A", index: "a01" },
+ { id: "B" },
+ { id: "C" },
+ { id: "D", index: "a01" },
+ { id: "E", index: "a01" },
+ { id: "F", index: "a01" },
+ { id: "G" },
+ { id: "I", index: "a03" },
+ { id: "H" },
+ ],
+ // missed "E" therefore upper bound for 'B' is a01, while lower bound is 'a02'
+ // therefore, similarly to above, it will fail during key generation and lead to fallback
+ movedElements: ["B", "C", "D", "F", "G", "H"],
+ expect: {
+ unchangedElements: ["A", "I"],
+ },
+ });
+ });
+ });
+});
+
+function testMovedIndicesSync(args: {
+ elements: { id: string; index?: string }[];
+ movedElements: string[];
+ expect: {
+ unchangedElements: string[];
+ validInput?: true;
+ };
+}) {
+ const [elements, movedElements] = prepareArguments(
+ args.elements,
+ args.movedElements,
+ );
+ const expectUnchangedElements = arrayToMap(
+ args.expect.unchangedElements.map((x) => ({ id: x })),
+ );
+
+ test(
+ "should sync invalid indices of moved elements or fallback",
+ elements,
+ movedElements,
+ expectUnchangedElements,
+ args.expect.validInput,
+ );
+}
+
+function testInvalidIndicesSync(args: {
+ elements: { id: string; index?: string }[];
+ expect: {
+ unchangedElements: string[];
+ validInput?: true;
+ };
+}) {
+ const [elements] = prepareArguments(args.elements);
+ const expectUnchangedElements = arrayToMap(
+ args.expect.unchangedElements.map((x) => ({ id: x })),
+ );
+
+ test(
+ "should sync invalid indices of all elements",
+ elements,
+ undefined,
+ expectUnchangedElements,
+ args.expect.validInput,
+ );
+}
+
+function prepareArguments(
+ elementsLike: { id: string; index?: string }[],
+ movedElementsIds?: string[],
+): [ExcalidrawElement[], Map<string, ExcalidrawElement> | undefined] {
+ const elements = elementsLike.map((x) =>
+ API.createElement({ id: x.id, index: x.index as FractionalIndex }),
+ );
+ const movedMap = arrayToMap(movedElementsIds || []);
+ const movedElements = movedElementsIds
+ ? arrayToMap(elements.filter((x) => movedMap.has(x.id)))
+ : undefined;
+
+ return [elements, movedElements];
+}
+
+function test(
+ name: string,
+ elements: ExcalidrawElement[],
+ movedElements: Map<string, ExcalidrawElement> | undefined,
+ expectUnchangedElements: Map<string, { id: string }>,
+ expectValidInput?: boolean,
+) {
+ it(name, () => {
+ // ensure the input is invalid (unless the flag is on)
+ if (!expectValidInput) {
+ expect(() =>
+ validateFractionalIndices(elements, {
+ shouldThrow: true,
+ includeBoundTextValidation: true,
+ ignoreLogs: true,
+ }),
+ ).toThrowError(InvalidFractionalIndexError);
+ }
+
+ // clone due to mutation
+ const clonedElements = elements.map((x) => deepCopyElement(x));
+
+ // act
+ const syncedElements = movedElements
+ ? syncMovedIndices(clonedElements, movedElements)
+ : syncInvalidIndices(clonedElements);
+
+ expect(syncedElements.length).toBe(elements.length);
+ expect(() =>
+ validateFractionalIndices(syncedElements, {
+ shouldThrow: true,
+ includeBoundTextValidation: true,
+ ignoreLogs: true,
+ }),
+ ).not.toThrowError(InvalidFractionalIndexError);
+
+ syncedElements.forEach((synced, index) => {
+ const element = elements[index];
+ // ensure the order hasn't changed
+ expect(synced.id).toBe(element.id);
+
+ if (expectUnchangedElements.has(synced.id)) {
+ // ensure we didn't mutate where we didn't want to mutate
+ expect(synced.index).toBe(elements[index].index);
+ expect(synced.version).toBe(elements[index].version);
+ } else {
+ expect(synced.index).not.toBe(elements[index].index);
+ // ensure we mutated just once, even with fallback triggered
+ expect(synced.version).toBe(elements[index].version + 1);
+ }
+ });
+ });
+}