/* 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 | 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 | undefined, expectUnchangedElements: Map, expectValidInput?: boolean, ) { it(name, () => { // ensure the input is invalid (unless the flag is on) if (!expectValidInput) { expect(() => validateFractionalIndices(elements.map((x) => x.index)), ).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.map((x) => x.index)), ).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); } }); }); }