aurelia/aurelia

View on GitHub
packages/__tests__/src/2-runtime/array-observer.spec.ts

Summary

Maintainability
F
1 mo
Test Coverage
import {
  ArrayObserver,
  copyIndexMap,
  disableArrayObservation,
  enableArrayObservation,
  ICollectionSubscriber,
  IndexMap,
  batch,
  Collection,
} from '@aurelia/runtime';
import {
  assert,
  CollectionChangeSet,
  eachCartesianJoin,
  SpySubscriber,
} from '@aurelia/testing';

const compareNumber = (a: number, b: number): number => a - b;
export class SynchronizingCollectionSubscriber implements ICollectionSubscriber {
  public readonly oldArr: unknown[];
  public readonly newArr: unknown[];

public constructor(
    oldArr: unknown[],
    newArr: unknown[],
  ) {
    this.oldArr = oldArr;
    this.newArr = newArr;
  }

  public handleCollectionChange(collection: Collection, indexMap: IndexMap): void {
    const newArr = this.newArr;
    const oldArr = this.oldArr;
    const oldArrCopy = oldArr.slice();

    const deleted = indexMap.deletedIndices.sort(compareNumber);
    for (let i = 0; i < deleted.length; ++i) {
      oldArr.splice(deleted[i] - i, 1);
    }

    for (let i = 0; i < indexMap.length; ++i) {
      if (indexMap[i] === -2) {
        oldArr.splice(i, 0, newArr[i]);
      }
    }

    for (let i = 0; i < indexMap.length; ++i) {
      const source = indexMap[i];
      if (source !== -2) {
        oldArr[i] = oldArrCopy[source];
      }
    }
  }

  // 17 tests fail (combined splice+sort)
  // public handleCollectionChange(collection: Collection, indexMap: IndexMap): void {
  //   const newArr = this.newArr;
  //   const oldArr = this.oldArr;
  //   const deleted = indexMap.deletedIndices.sort(compareNumber);
  //   let cursor = 0;
  //   let dCurr = deleted[cursor] ?? -1;
  //   let deletes = 0;
  //   const deleteOffsetMap = Array(indexMap.length + deleted.length).fill(0);
  //   for (let i = 0; i < deleteOffsetMap.length; ++i) {
  //     while (i === dCurr) {
  //       oldArr.splice(dCurr, 1);
  //       ++deletes;
  //       if (deleted.length > ++cursor) {
  //         dCurr = deleted[cursor] - deletes;
  //       } else {
  //         dCurr = -1;
  //       }
  //     }
  //     deleteOffsetMap[i] = deletes;
  //   }

  //   let adds = 0;
  //   const addsOffsetMap = Array(indexMap.length + deleted.length).fill(0);
  //   for (let i = 0; i < addsOffsetMap.length; ++i) {
  //     if (indexMap[i] === -2 && indexMap[i - 1] !== -2) {
  //       let j = i;
  //       while (indexMap[j] === -2) {
  //         oldArr.splice(j, 0, newArr[j]);
  //         ++adds;
  //         ++j;
  //       }
  //     }
  //     addsOffsetMap[i] = adds;
  //   }

  //   const oldArrCopy = oldArr.slice();
  //   for (let i = 0; i < indexMap.length; ++i) {
  //     const source = indexMap[i];
  //     const offset = addsOffsetMap[i] - deleteOffsetMap[i];
  //     if (source !== -2 && source + offset !== i) {
  //       oldArr[i] = oldArrCopy[source + offset];
  //     }
  //   }
  // }

  // 16 different tests fail (push + shift / multi splice combi's)
  // public handleCollectionChange(collection: Collection, indexMap: IndexMap): void {
  //   const newArr = this.newArr;
  //   const oldArr = this.oldArr;
  //   const deleted = indexMap.deletedIndices.sort(compareNumber);
  //   let cursor = 0;
  //   let dCurr = deleted[cursor] ?? -1;
  //   let deletes = 0;
  //   const deleteOffsetMap = Array(indexMap.length + deleted.length).fill(0);
  //   for (let i = 0; i < deleteOffsetMap.length; ++i) {
  //     while (i === dCurr) {
  //       oldArr.splice(dCurr, 1);
  //       ++deletes;
  //       if (deleted.length > ++cursor) {
  //         dCurr = deleted[cursor] - deletes;
  //       } else {
  //         dCurr = -1;
  //       }
  //     }
  //     deleteOffsetMap[i] = deletes;
  //   }

  //   let adds = 0;
  //   const addsOffsetMap = Array(indexMap.length + deleted.length).fill(0);
  //   for (let i = 0; i < addsOffsetMap.length; ++i) {
  //     if (indexMap[i] === -2 && indexMap[i - 1] !== -2) {
  //       let j = i;
  //       while (indexMap[j] === -2) {
  //         oldArr.splice(j, 0, newArr[j]);
  //         ++adds;
  //         ++j;
  //       }
  //     }
  //     addsOffsetMap[i] = adds;
  //   }

  //   const oldArrCopy = oldArr.slice();
  //   for (let i = 0; i < indexMap.length; ++i) {
  //     const source = indexMap[i];
  //     const offset = addsOffsetMap[source] - deleteOffsetMap[source];
  //     if (source !== -2 && source + offset !== i) {
  //       oldArr[i] = oldArrCopy[source + offset];
  //     }
  //   }
  // }
}

describe(`2-runtime/array-observer.spec.ts`, function () {
  let sut: ArrayObserver;

  before(function () {
    disableArrayObservation();
    enableArrayObservation();
  });

  describe('should allow subscribing for batched notification', function () {
    const observerMap = new WeakMap<unknown[], ArrayObserver>();
    function verifyChanges(arr: number[], fn: (arr: number[]) => void, existing: number[], deletedIndices?: number[], deletedItems?: number[]) {
      const s = new SpySubscriber();
      const sut = observerMap.get(arr) ?? (observerMap.set(arr, new ArrayObserver(arr)).get(arr));
      sut.subscribe(s);

      try {
        batch(() => {
          fn(arr);
        });
      } catch (ex) {
        console.log(ex);
      }

      if (s.collectionChanges.length === 0) {
        throw new Error('No changes has happened');
      }

      assert.deepStrictEqual(
        s.collectionChanges.pop(),
        new CollectionChangeSet(0, copyIndexMap(existing, deletedIndices, deletedItems)),
      );
    }

    function verifyNoChanges(arr: number[], fn: (arr: number[]) => void) {
      const s = new SpySubscriber();
      const sut = new ArrayObserver(arr);
      sut.subscribe(s);

      batch(() => {
        fn(arr);
      });

      assert.strictEqual(s.collectionChanges.length, 0);
    }

    function asc(a: number, b: number) {
      if (a === b) return 0;
      return a > b ? 1 : -1;
    }
    function desc(a: number, b: number) {
      if (a === b) return 0;
      return a > b ? -1 : 1;
    }

    describe('empty array', function () {
      it('2x push', function () {
        verifyChanges([], arr => {
          arr.push(1);
          arr.push(2);
        }, [-2, -2]);
      });

      it('2x unshift', function () {
        verifyChanges([], arr => {
          arr.unshift(1);
          arr.unshift(2);
        }, [-2, -2]);
      });

      it('2x push + 2x unshift', function () {
        verifyChanges([], arr => {
          arr.push(1);
          arr.push(2);
          arr.unshift(3);
          arr.unshift(4);
        }, [-2, -2, -2, -2]);
      });

      it('push + pop', function () {
        verifyNoChanges([], arr => {
          arr.push(1);
          arr.pop();
        });
      });

      it('unshift + shift', function () {
        verifyNoChanges([], arr => {
          arr.unshift(1);
          arr.shift();
        });
      });

      it('push + push + pop', function () {
        verifyChanges([], arr => {
          arr.push(1);
          arr.push(2);
          arr.pop();
        }, [-2]);
      });
    });

    describe('array w/ 1 item', function () {
      it('2x push', function () {
        verifyChanges([1], arr => {
          arr.push(2);
          arr.push(3);
        }, [0, -2, -2]);
      });

      it('2x unshift', function () {
        verifyChanges([1], arr => {
          arr.unshift(2);
          arr.unshift(3);
        }, [-2, -2, 0]);
      });

      it('2x push + 2x unshift', function () {
        verifyChanges([1], arr => {
          arr.push(2);
          arr.push(3);
          arr.unshift(4);
          arr.unshift(5);
        }, [-2, -2, 0, -2, -2]);
      });

      it('push + pop', function () {
        verifyNoChanges([1], arr => {
          arr.push(2);
          arr.pop();
        });
      });

      it('unshift + shift', function () {
        verifyNoChanges([1], arr => {
          arr.unshift(2);
          arr.shift();
        });
      });

      it('push + push + pop', function () {
        verifyChanges([1], arr => {
          arr.push(2);
          arr.push(3);
          arr.pop();
        }, [0, -2]);
      });

      it('push + shift', function () {
        verifyChanges([1], arr => {
          arr.push(2);
          arr.shift();
        }, [-2], [0], [1]);
      });

      it('push + push + shift', function () {
        verifyChanges([1], arr => {
          arr.push(2);
          arr.push(3);
          arr.shift();
        }, [-2, -2], [0], [1]);
      });

      it('push + splice(0, 2, 3, 4)', function () {
        verifyChanges([1], arr => {
          arr.push(2);
          arr.splice(0, 2, 3, 4);
        }, [-2, -2], [0], [1]);
      });

      it('splice(1, 0, 2) + splice(1, 1)', function () {
        verifyNoChanges([1], arr => {
          arr.splice(1, 0, 2);
          arr.splice(1, 1);
        });
      });
    });

    describe('array w/ 2 item', function () {
      it('2x push', function () {
        verifyChanges([1, 2], arr => {
          arr.push(3);
          arr.push(4);
        }, [0, 1, -2, -2]);
      });

      it('2x unshift', function () {
        verifyChanges([1, 2], arr => {
          arr.unshift(3);
          arr.unshift(4);
        }, [-2, -2, 0, 1]);
      });

      it('2x push + 2x unshift', function () {
        verifyChanges([1, 2], arr => {
          arr.push(3);
          arr.push(4);
          arr.unshift(5);
          arr.unshift(6);
        }, [-2, -2, 0, 1, -2, -2]);
      });

      it('push + pop', function () {
        verifyNoChanges([1, 2], arr => {
          arr.push(3);
          arr.pop();
        });
      });

      it('unshift + shift', function () {
        verifyNoChanges([1, 2], arr => {
          arr.unshift(3);
          arr.shift();
        });
      });

      it('push + push + pop', function () {
        verifyChanges([1, 2], arr => {
          arr.push(3);
          arr.push(4);
          arr.pop();
        }, [0, 1, -2]);
      });

      it('push + shift', function () {
        verifyChanges([1, 2], arr => {
          arr.push(3);
          arr.shift();
        }, [1, -2], [0], [1]);
      });

      it('push + push + shift', function () {
        verifyChanges([1, 2], arr => {
          arr.push(3);
          arr.push(4);
          arr.shift();
        }, [1, -2, -2], [0], [1]);
      });

      it('push + splice(0, 2, 4, 5)', function () {
        verifyChanges([1, 2], arr => {
          arr.push(3);
          arr.splice(0, 2, 4, 5);
        }, [-2, -2, -2], [0, 1], [1, 2]);
      });

      it('splice(1, 0, 2) + splice(1, 1)', function () {
        verifyNoChanges([1, 3], arr => {
          arr.splice(1, 0, 2);
          arr.splice(1, 1);
        });
      });

      it('push + reverse', function () {
        verifyChanges([1, 2], arr => {
          arr.push(3);
          arr.reverse();
        }, [-2, 1, 0]);
      });

      it('reverse + reverse', function () {
        verifyNoChanges([1, 2], arr => {
          arr.reverse();
          arr.reverse();
        });
      });

      it('reverse + reverse + reverse', function () {
        verifyChanges([1, 2], arr => {
          arr.reverse();
          arr.reverse();
          arr.reverse();
        }, [1, 0]);
      });

      it('push + sort(a>b?-1:1)', function () {
        verifyChanges([1, 2], arr => {
          arr.push(3);
          arr.sort(desc);
        }, [-2, 1, 0]);
      });

      it('push + sort(asc)', function () {
        verifyChanges([1, 2], arr => {
          arr.push(3);
          arr.sort(asc);
        }, [0, 1, -2]);
      });

      it('sort(a>b?-1:1) + sort(asc)', function () {
        verifyNoChanges([1, 2], arr => {
          arr.sort(desc);
          arr.sort(asc);
        });
      });

      it('sort(a>b?-1:1) + sort(asc) + sort(a>b?-1:1)', function () {
        verifyChanges([1, 2], arr => {
          arr.sort(desc);
          arr.sort(asc);
          arr.sort(desc);
        }, [1, 0]);
      });
    });

    describe('array w/ 4 item', function () {
      // note: these tests were failing on synchronization during implementation but succeeded here; the error was in the applyMutationsToIndices
      it('splice the first three items each with two new items in reverse order', function () {
        verifyChanges([1, 2, 3, 4], arr => {
          arr.splice(2, 1, 5, 6); // 1, 2, [5], [6], 4
          arr.splice(1, 1, 3, 4); // 1, [3], [4], [5], [6], 4
          arr.splice(0, 1, 1, 2); // [1], [2], [3], [4], [5], [6], 4
        }, [-2, -2, -2, -2, -2, -2, 3], [2, 1, 0], [3, 2, 1]);
      });

      it('splice the first two items each with two new items in reverse order', function () {
        verifyChanges([1, 2, 3, 4], arr => {
          arr.splice(1, 1, 3, 4); // 1, [3], [4], 3, 4
          arr.splice(0, 1, 1, 2); // [1], [2], [3], [4], 3, 4
        }, [-2, -2, -2, -2, 2, 3], [1, 0], [2, 1]);
      });

      it('splice the middle two items each with two new items in reverse order', function () {
        verifyChanges([1, 2, 3, 4], arr => {
          arr.splice(2, 1, 3, 4); // 1, 2, [3], [4], 4
          arr.splice(1, 1, 1, 2); // 1, [1], [2], [3], [4], 4
        }, [0, -2, -2, -2, -2, 3], [2, 1], [3, 2]);
      });

      it('splice the first and third items each with two new items in reverse order', function () {
        verifyChanges([1, 2, 3, 4], arr => {
          arr.splice(2, 1, 5, 6); // 1, 2, [5], [6], 4
          arr.splice(0, 1, 7, 8); // [7], [8], 2, [5], [6], 4
        }, [-2, -2, 1, -2, -2, 3], [2, 0], [3, 1]);
      });

      it('splice the second and fourth items each with two new items in reverse order', function () {
        verifyChanges([1, 2, 3, 4], arr => {
          arr.splice(3, 1, 5, 6); // 1, 2, 3, [5], [6]
          arr.splice(1, 1, 7, 8); // 1, [7], [8], 3, [5], [6]
        }, [0, -2, -2, 2, -2, -2], [3, 1], [4, 2]);
      });
    });

    it('works with double nested batch', function () {
      const arr = [1, 2, 3];
      const o = new ArrayObserver(arr);
      let map: IndexMap;
      let callCount = 0;
      o.subscribe({
        handleCollectionChange(_collection, indexMap) {
          map = indexMap;
          callCount++;
        },
      });
      batch(() => {
        arr.splice(1, 1, 5);
        assert.deepStrictEqual(arr, [1, 5, 3]);
        batch(() => {
          arr.splice(0, 1, 7);
        });
        assert.strictEqual(callCount, 1);
        assert.deepStrictEqual(arr, [7, 5, 3]);
        assert.deepStrictEqual(
          map,
          Object.assign([-2, -2, 2], { deletedIndices: [1, 0], deletedItems: [2, 1], isIndexMap: true })
        );
      });
      assert.strictEqual(callCount, 2);
      assert.deepStrictEqual(
        map,
        Object.assign([-2, -2, 2], { deletedIndices: [1, 0], deletedItems: [2, 1], isIndexMap: true })
      );
    });
  });

  describe('should allow unsubscribing for batched notification', function () {
    it('push', function () {
      const s = new SpySubscriber();
      const arr = [];
      sut = new ArrayObserver(arr);
      sut.subscribe(s);
      sut.unsubscribe(s);
      batch(() => {
        arr.push(1);
      });
      assert.strictEqual(s.collectionChanges.length, 0);
    });
  });

  describe('should not notify batched subscribers if there are no changes', function () {
    it('push', function () {
      const s = new SpySubscriber();
      const arr = [];
      sut = new ArrayObserver(arr);
      batch(() => { /* do nothing */ });
      assert.strictEqual(s.collectionChanges.length, 0);
    });
  });

  describe('synchronize batched indexMap changes', function () {
    function verifyChanges(arr: symbol[], fn: (arr: symbol[]) => void) {
      const copy = arr.slice();
      const s = new SynchronizingCollectionSubscriber(copy, arr);
      const sut = new ArrayObserver(arr);
      sut.subscribe(s);

      batch(() => {
        fn(arr);
      });

      assert.deepStrictEqual(copy, arr);
    }

    function asc(a: symbol, b: symbol) {
      const $a = a.toString();
      const $b = b.toString();
      if ($a === $b) return 0;
      return $a > $b ? 1 : -1;
    }
    function desc(a: symbol, b: symbol) {
      const $a = a.toString();
      const $b = b.toString();
      if ($a === $b) return 0;
      return $a > $b ? -1 : 1;
    }

    const S = Symbol;

    describe('empty array', function () {
      it('2x push', function () {
        verifyChanges([], arr => {
          arr.push(S(1));
          arr.push(S(2));
        });
      });

      it('2x unshift', function () {
        verifyChanges([], arr => {
          arr.unshift(S(1));
          arr.unshift(S(2));
        });
      });

      it('2x push + 2x unshift', function () {
        verifyChanges([], arr => {
          arr.push(S(1));
          arr.push(S(2));
          arr.unshift(S(3));
          arr.unshift(S(4));
        });
      });

      it('push + pop', function () {
        verifyChanges([], arr => {
          arr.push(S(1));
          arr.pop();
        });
      });

      it('unshift + shift', function () {
        verifyChanges([], arr => {
          arr.unshift(S(1));
          arr.shift();
        });
      });

      it('push + push + pop', function () {
        verifyChanges([], arr => {
          arr.push(S(1));
          arr.push(S(2));
          arr.pop();
        });
      });
    });

    describe('array w/ 1 item', function () {
      it('2x push', function () {
        verifyChanges([S(1)], arr => {
          arr.push(S(2));
          arr.push(S(3));
        });
      });

      it('2x unshift', function () {
        verifyChanges([S(1)], arr => {
          arr.unshift(S(2));
          arr.unshift(S(3));
        });
      });

      it('2x push + 2x unshift', function () {
        verifyChanges([S(1)], arr => {
          arr.push(S(2));
          arr.push(S(3));
          arr.unshift(S(4));
          arr.unshift(S(5));
        });
      });

      it('push + pop', function () {
        verifyChanges([S(1)], arr => {
          arr.push(S(2));
          arr.pop();
        });
      });

      it('unshift + shift', function () {
        verifyChanges([S(1)], arr => {
          arr.unshift(S(2));
          arr.shift();
        });
      });

      it('push + push + pop', function () {
        verifyChanges([S(1)], arr => {
          arr.push(S(2));
          arr.push(S(3));
          arr.pop();
        });
      });

      it('push + shift', function () {
        verifyChanges([S(1)], arr => {
          arr.push(S(2));
          arr.shift();
        });
      });

      it('push + push + shift', function () {
        verifyChanges([S(1)], arr => {
          arr.push(S(2));
          arr.push(S(3));
          arr.shift();
        });
      });

      it('push + splice(0, 2, 3, 4)', function () {
        verifyChanges([S(1)], arr => {
          arr.push(S(2));
          arr.splice(0, 2, S(3), S(4));
        });
      });

      it('splice(1, 0, 2) + splice(1, 1)', function () {
        verifyChanges([S(1)], arr => {
          arr.splice(1, 0, S(2));
          arr.splice(1, 1);
        });
      });
    });

    describe('array w/ 2 item', function () {
      it('2x push', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.push(S(3));
          arr.push(S(4));
        });
      });

      it('2x unshift', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.unshift(S(3));
          arr.unshift(S(4));
        });
      });

      it('2x push + 2x unshift', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.push(S(3));
          arr.push(S(4));
          arr.unshift(S(5));
          arr.unshift(S(6));
        });
      });

      it('push + pop', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.push(S(3));
          arr.pop();
        });
      });

      it('unshift + shift', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.unshift(S(3));
          arr.shift();
        });
      });

      it('push + push + pop', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.push(S(3));
          arr.push(S(4));
          arr.pop();
        });
      });

      it('push + shift', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.push(S(3));
          arr.shift();
        });
      });

      it('push + push + shift', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.push(S(3));
          arr.push(S(4));
          arr.shift();
        });
      });

      it('push + splice(0, 2, 3, 4)', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.push(S(3));
          arr.splice(0, 2, S(4), S(5));
        });
      });

      it('splice(1, 0, 2) + splice(1, 1)', function () {
        verifyChanges([S(1), S(3)], arr => {
          arr.splice(1, 0, S(2));
          arr.splice(1, 1);
        });
      });

      it('push + reverse', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.push(S(3));
          arr.reverse();
        });
      });

      it('reverse + reverse', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.reverse();
          arr.reverse();
        });
      });

      it('reverse + reverse + reverse', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.reverse();
          arr.reverse();
          arr.reverse();
        });
      });

      it('push + sort(desc)', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.push(S(3));
          arr.sort(desc);
        });
      });

      it('push + sort(asc)', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.push(S(3));
          arr.sort(asc);
        });
      });

      it('sort(desc) + sort(asc)', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.sort(desc);
          arr.sort(asc);
        });
      });

      it('sort(desc) + sort(asc) + sort(desc)', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.sort(desc);
          arr.sort(asc);
          arr.sort(desc);
        });
      });
    });

    describe('array w/ 3 item', function () {
      it('2x push', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.push(S(4));
          arr.push(S(5));
        });
      });

      it('2x unshift', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.unshift(S(4));
          arr.unshift(S(5));
        });
      });

      it('2x push + 2x unshift', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.push(S(4));
          arr.push(S(5));
          arr.unshift(S(6));
          arr.unshift(S(7));
        });
      });

      it('push + pop', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.push(S(4));
          arr.pop();
        });
      });

      it('unshift + shift', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.unshift(S(4));
          arr.shift();
        });
      });

      it('push + push + pop', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.push(S(4));
          arr.push(S(5));
          arr.pop();
        });
      });

      it('push + shift', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.push(S(4));
          arr.shift();
        });
      });

      it('push + push + shift', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.push(S(4));
          arr.push(S(5));
          arr.shift();
        });
      });

      it('push + splice(0, 2, 5, 6)', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.push(S(4));
          arr.splice(0, 2, S(5), S(6));
        });
      });

      it('splice(1, 0, 2) + splice(1, 1)', function () {
        verifyChanges([S(1), S(3), S(4)], arr => {
          arr.splice(1, 0, S(2));
          arr.splice(1, 1);
        });
      });

      it('splice(1, 0, 2, 3) + splice(1, 1)', function () {
        verifyChanges([S(1), S(4), S(5)], arr => {
          arr.splice(1, 0, S(2), S(3));
          arr.splice(1, 1);
        });
      });

      it('splice each item with two new items', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.splice(0, 1, S(1), S(2));
          arr.splice(2, 1, S(3), S(4));
          arr.splice(4, 1, S(5), S(6));
        });
      });

      it('splice each item with two new items and sort asc in-between', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.splice(0, 1, S(1), S(2));
          arr.sort(asc);
          arr.splice(2, 1, S(3), S(4));
          arr.sort(asc);
          arr.splice(4, 1, S(5), S(6));
          arr.sort(asc);
        });
      });

      it('splice each item with two new items and sort desc in-between', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.splice(0, 1, S(1), S(2));
          arr.sort(desc);
          arr.splice(2, 1, S(3), S(4));
          arr.sort(desc);
          arr.splice(4, 1, S(5), S(6));
          arr.sort(desc);
        });
      });

      it('splice each item with two new items and sort alternating asc & desc in-between', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.splice(0, 1, S(1), S(2));
          arr.sort(asc);
          arr.splice(2, 1, S(3), S(4));
          arr.sort(desc);
          arr.splice(4, 1, S(5), S(6));
          arr.sort(asc);
        });
      });

      it('splice the first two items each with two new items in reverse order', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.splice(1, 1, S(4), S(5));
          arr.splice(0, 1, S(6), S(7));
        });
      });

      it('splice each item with two new items in reverse order', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.splice(2, 1, S(5), S(6));
          arr.splice(1, 1, S(3), S(4));
          arr.splice(0, 1, S(1), S(2));
        });
      });

      it('splice the middle item with three new items and sort asc', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.splice(1, 1, S(2), S(4), S(5));
          arr.sort(asc);
        });
      });

      it('splice the middle item with three new items and sort desc', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.splice(1, 1, S(2), S(4), S(5));
          arr.sort(desc);
        });
      });

      it('push + reverse', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.push(S(4));
          arr.reverse();
        });
      });

      it('reverse + reverse', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.reverse();
          arr.reverse();
        });
      });

      it('reverse + reverse + reverse', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.reverse();
          arr.reverse();
          arr.reverse();
        });
      });

      it('push + sort(desc)', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.push(S(4));
          arr.sort(desc);
        });
      });

      it('push + sort(asc)', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.push(S(4));
          arr.sort(asc);
        });
      });

      it('sort(desc) + sort(asc)', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.sort(desc);
          arr.sort(asc);
        });
      });

      it('sort(desc) + sort(asc) + sort(desc)', function () {
        verifyChanges([S(1), S(2), S(3)], arr => {
          arr.sort(desc);
          arr.sort(asc);
          arr.sort(desc);
        });
      });

      describe('combined splice+sort operations', function () {
        it('swap outer items + delete inner item', function () {
          verifyChanges([S(1), S(2), S(3)], arr => {
            arr.reverse();
            arr.splice(1, 1);
          });
        });

        it('delete inner item + swap outer items', function () {
          verifyChanges([S(1), S(2), S(3)], arr => {
            arr.splice(1, 1);
            arr.reverse();
          });
        });

        it('swap outer items + replace inner item', function () {
          verifyChanges([S(1), S(3), S(4)], arr => {
            arr.reverse();
            arr.splice(1, 1, S(2));
          });
        });

        it('replace inner item + swap outer items', function () {
          verifyChanges([S(1), S(3), S(4)], arr => {
            arr.splice(1, 1, S(2));
            arr.reverse();
          });
        });

        it('swap outer items + add inner item', function () {
          verifyChanges([S(1), S(3), S(4)], arr => {
            arr.reverse();
            arr.splice(1, 0, S(2));
          });
        });

        it('add inner item + swap outer items', function () {
          verifyChanges([S(1), S(3), S(4)], arr => {
            arr.splice(1, 0, S(2));
            arr.reverse();
          });
        });
      });
    });

    describe('array w/ 4 item', function () {
      it('2x push', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.push(S(5));
          arr.push(S(6));
        });
      });

      it('2x unshift', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.unshift(S(5));
          arr.unshift(S(6));
        });
      });

      it('2x push + 2x unshift', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.push(S(5));
          arr.push(S(6));
          arr.unshift(S(7));
          arr.unshift(S(8));
        });
      });

      it('push + pop', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.push(S(5));
          arr.pop();
        });
      });

      it('unshift + shift', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.unshift(S(5));
          arr.shift();
        });
      });

      it('push + push + pop', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.push(S(5));
          arr.push(S(6));
          arr.pop();
        });
      });

      it('push + shift', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.push(S(5));
          arr.shift();
        });
      });

      it('push + push + shift', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.push(S(5));
          arr.push(S(6));
          arr.shift();
        });
      });

      it('push + splice(0, 2, 5, 6)', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.push(S(5));
          arr.splice(0, 2, S(6), S(7));
        });
      });

      it('splice(1, 0, 2) + splice(1, 1)', function () {
        verifyChanges([S(1), S(3), S(5), S(6)], arr => {
          arr.splice(1, 0, S(2));
          arr.splice(1, 1);
        });
      });

      it('splice(1, 0, 2, 3) + splice(1, 1)', function () {
        verifyChanges([S(1), S(5), S(6), S(7)], arr => {
          arr.splice(1, 0, S(2), S(3));
          arr.splice(1, 1);
        });
      });

      it('splice the first three items each with two new items', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(0, 1, S(1), S(2));
          arr.splice(2, 1, S(3), S(4));
          arr.splice(4, 1, S(5), S(6));
        });
      });

      it('splice the first three items each with two new items and sort asc in-between', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(0, 1, S(5), S(6));
          arr.sort(asc);
          arr.splice(2, 1, S(7), S(8));
          arr.sort(asc);
          arr.splice(4, 1, S(9), S(10));
          arr.sort(asc);
        });
      });

      it('splice the first three items each with two new items and sort desc in-between', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(0, 1, S(5), S(6));
          arr.sort(desc);
          arr.splice(2, 1, S(7), S(8));
          arr.sort(desc);
          arr.splice(4, 1, S(9), S(10));
          arr.sort(desc);
        });
      });

      it('splice the first three items each with two new items and sort alternating asc & desc in-between', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(0, 1, S(5), S(6));
          arr.sort(asc);
          arr.splice(2, 1, S(7), S(8));
          arr.sort(desc);
          arr.splice(4, 1, S(9), S(10));
          arr.sort(asc);
        });
      });

      it('splice the first three items each with two new items in reverse order', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(2, 1, S(5), S(6));
          arr.splice(1, 1, S(3), S(4));
          arr.splice(0, 1, S(1), S(2));
        });
      });

      it('splice the last three items each with two new items in reverse order', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(3, 1, S(5), S(6));
          arr.splice(2, 1, S(3), S(4));
          arr.splice(1, 1, S(1), S(2));
        });
      });

      it('splice the first two items each with two new items in reverse order', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(1, 1, S(5), S(6));
          arr.splice(0, 1, S(7), S(8));
        });
      });

      it('splice the middle two items each with two new items in reverse order', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(2, 1, S(5), S(6));
          arr.splice(1, 1, S(7), S(8));
        });
      });

      it('splice the last two items each with two new items in reverse order', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(3, 1, S(5), S(6));
          arr.splice(2, 1, S(7), S(8));
        });
      });

      it('splice the first and third items each with two new items in reverse order', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(2, 1, S(5), S(6));
          arr.splice(0, 1, S(7), S(8));
        });
      });

      it('splice the second and fourth items each with two new items in reverse order', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(3, 1, S(5), S(6));
          arr.splice(1, 1, S(7), S(8));
        });
      });

      it('splice each item with two new items', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(0, 1, S(1), S(2));
          arr.splice(2, 1, S(3), S(4));
          arr.splice(4, 1, S(5), S(6));
          arr.splice(6, 1, S(7), S(8));
        });
      });

      it('splice each item with two new items and sort asc in-between', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(0, 1, S(5), S(6));
          arr.sort(asc);
          arr.splice(2, 1, S(7), S(8));
          arr.sort(asc);
          arr.splice(4, 1, S(9), S(10));
          arr.sort(asc);
          arr.splice(6, 1, S(11), S(12));
          arr.sort(asc);
        });
      });

      it('splice each item with two new items and sort desc in-between', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(0, 1, S(5), S(6));
          arr.sort(desc);
          arr.splice(2, 1, S(7), S(8));
          arr.sort(desc);
          arr.splice(4, 1, S(9), S(10));
          arr.sort(desc);
          arr.splice(6, 1, S(11), S(12));
          arr.sort(desc);
        });
      });

      it('splice each item with two new items and sort alternating asc & desc in-between', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(0, 1, S(5), S(6));
          arr.sort(asc);
          arr.splice(2, 1, S(7), S(8));
          arr.sort(desc);
          arr.splice(4, 1, S(9), S(10));
          arr.sort(asc);
          arr.splice(6, 1, S(11), S(12));
          arr.sort(desc);
        });
      });

      it('splice each item with two new items in reverse order', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(3, 1, S(7), S(8));
          arr.splice(2, 1, S(5), S(6));
          arr.splice(1, 1, S(3), S(4));
          arr.splice(0, 1, S(1), S(2));
        });
      });

      it('splice the middle item with three new items and sort asc', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(1, 1, S(2), S(5), S(6));
          arr.sort(asc);
        });
      });

      it('splice the middle item with three new items and sort desc', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.splice(1, 1, S(2), S(5), S(6));
          arr.sort(desc);
        });
      });

      it('splice the middle item with three new items and sort desc ---', function () {
        verifyChanges([S(1), S(2)], arr => {
          arr.splice(1, 1, S(3), S(4));
          arr.reverse();
        });
      });

      it('push + reverse', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.push(S(5));
          arr.reverse();
        });
      });

      it('reverse + reverse', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.reverse();
          arr.reverse();
        });
      });

      it('reverse + reverse + reverse', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.reverse();
          arr.reverse();
          arr.reverse();
        });
      });

      it('push + sort(desc)', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.push(S(5));
          arr.sort(desc);
        });
      });

      it('push + sort(asc)', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.push(S(5));
          arr.sort(asc);
        });
      });

      it('sort(desc) + sort(asc)', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.sort(desc);
          arr.sort(asc);
        });
      });

      it('sort(desc) + sort(asc) + sort(desc)', function () {
        verifyChanges([S(1), S(2), S(3), S(4)], arr => {
          arr.sort(desc);
          arr.sort(asc);
          arr.sort(desc);
        });
      });

      describe('combined splice+sort operations', function () {
        it('swap outer items + delete inner items', function () {
          verifyChanges([S(1), S(2), S(3), S(4)], arr => {
            arr.reverse();
            arr.splice(1, 2);
          });
        });

        it('delete inner items + swap outer items', function () {
          verifyChanges([S(1), S(2), S(3), S(4)], arr => {
            arr.splice(1, 2);
            arr.reverse();
          });
        });

        it('swap outer items + replace inner items', function () {
          verifyChanges([S(1), S(4), S(5), S(6)], arr => {
            arr.reverse();
            arr.splice(1, 2, S(2), S(3));
          });
        });

        it('replace inner items + swap outer items', function () {
          verifyChanges([S(1), S(4), S(5), S(6)], arr => {
            arr.splice(1, 2, S(2), S(3));
            arr.reverse();
          });
        });
      });
    });
  });

  describe('should allow subscribing for immediate notification', function () {
    it('push', function () {
      const s = new SpySubscriber();
      const arr = [];
      sut = new ArrayObserver(arr);
      sut.subscribe(s);
      arr.push(1);
      assert.deepStrictEqual(
        s.collectionChanges.pop(),
        new CollectionChangeSet(0, copyIndexMap([-2]))
      );
    });

    it('push 2', function () {
      const s = new SpySubscriber();
      const arr = [];
      sut = new ArrayObserver(arr);
      sut.subscribe(s);
      arr.push(1, 2);
      assert.deepStrictEqual(
        s.collectionChanges.pop(),
        new CollectionChangeSet(0, copyIndexMap([-2, -2]))
      );
    });
  });

  describe('should allow unsubscribing for immediate notification', function () {
    it('push', function () {
      const s = new SpySubscriber();
      const arr = [];
      sut = new ArrayObserver(arr);
      sut.subscribe(s);
      sut.unsubscribe(s);
      arr.push(1);
      assert.strictEqual(s.collectionChanges.length, 0);
    });
  });

  // describe('should allow subscribing for batched notification', function () {
  //   it('push', function () {
  //     const s = new SpySubscriber();
  //     const arr = [];
  //     sut = new ArrayObserver(arr);
  //     sut.subscribe(s);
  //     batch(
  //       function () {
  //         arr.push(1);
  //       },
  //     );
  //     assert.deepStrictEqual(
  //       s.collectionChanges.pop(),
  //       new CollectionChangeSet(0, LF.none, copyIndexMap([-2]))
  //     );
  //   });

  //   it('push 2', function () {
  //     const s = new SpySubscriber();
  //     const arr = [];
  //     sut = new ArrayObserver(arr);
  //     sut.subscribe(s);
  //     batch(
  //       function () {
  //         arr.push(1, 2);
  //       },
  //     );
  //     assert.deepStrictEqual(
  //       s.collectionChanges.pop(),
  //       new CollectionChangeSet(0, LF.none, copyIndexMap([-2, -2]))
  //     );
  //   });
  // });

  describe(`observePush`, function () {
    const initArr = [[], [1], [1, 2]];
    const itemsArr = [undefined, [], [1], [1, 2]];
    const repeatArr = [1, 2];

    eachCartesianJoin(
      [
        initArr,
        itemsArr,
        repeatArr,
      ],
      function (init, items, repeat) {
        it(`size=${padRight(init.length, 2)} itemCount=${padRight(items?.length, 9)} repeat=${repeat} - behaves as native`, function () {
          const arr = init.slice();
          const expectedArr = init.slice();
          const newItems = items?.slice();
          sut = new ArrayObserver(arr);
          let expectedResult;
          let actualResult;
          let i = 0;
          while (i < repeat) {
            incrementItems(newItems, i);
            if (newItems === undefined) {
              expectedResult = expectedArr.push();
              actualResult = arr.push();
            } else {
              expectedResult = expectedArr.push(...newItems);
              actualResult = arr.push(...newItems);
            }
            assert.deepStrictEqual(actualResult, expectedResult);
            assert.deepStrictEqual(arr, expectedArr);
            i++;
          }
        });

        it(`size=${padRight(init.length, 2)} itemCount=${padRight(items?.length, 9)} repeat=${repeat} - tracks changes`, function () {
          const arr = init.slice();
          const copy = init.slice();
          const newItems = items?.slice();
          sut = new ArrayObserver(arr);
          sut.subscribe(new SynchronizingCollectionSubscriber(copy, arr));

          let i = 0;
          while (i < repeat) {
            incrementItems(newItems, i);
            if (newItems === undefined) {
              arr.push();
            } else {
              arr.push(...newItems);
            }
            assert.deepStrictEqual(copy, arr);
            i++;
          }
        });
      },
    );
  });

  describe(`observeUnshift`, function () {
    const initArr = [[], [1], [1, 2]];
    const itemsArr = [undefined, [], [1], [1, 2]];
    const repeatArr = [1, 2];

    eachCartesianJoin(
      [
        initArr,
        itemsArr,
        repeatArr,
      ],
      function (init, items, repeat) {
        it(`size=${padRight(init.length, 2)} itemCount=${padRight(items?.length, 9)} repeat=${repeat} - behaves as native`, function () {
          const arr = init.slice();
          const expectedArr = init.slice();
          const newItems = items?.slice();
          sut = new ArrayObserver(arr);
          let expectedResult;
          let actualResult;
          let i = 0;
          while (i < repeat) {
            incrementItems(newItems, i);
            if (newItems === undefined) {
              expectedResult = expectedArr.unshift();
              actualResult = arr.unshift();
            } else {
              expectedResult = expectedArr.unshift(...newItems);
              actualResult = arr.unshift(...newItems);
            }
            assert.deepStrictEqual(actualResult, expectedResult);
            assert.deepStrictEqual(arr, expectedArr);
            i++;
          }
        });

        it(`size=${padRight(init.length, 2)} itemCount=${padRight(items?.length, 9)} repeat=${repeat} - tracks changes`, function () {
          const arr = init.slice();
          const copy = init.slice();
          const newItems = items?.slice();
          sut = new ArrayObserver(arr);
          sut.subscribe(new SynchronizingCollectionSubscriber(copy, arr));
          let i = 0;
          while (i < repeat) {
            incrementItems(newItems, i);
            if (newItems === undefined) {
              arr.unshift();
            } else {
              arr.unshift(...newItems);
            }
            assert.deepStrictEqual(copy, arr);
            i++;
          }
        });
      },
    );
  });

  describe(`observePop`, function () {
    const initArr = [[], [1], [1, 2]];
    const repeatArr = [1, 2, 3, 4];

    eachCartesianJoin(
      [
        initArr,
        repeatArr,
      ],
      function (init, repeat) {
        it(`size=${padRight(init.length, 2)} repeat=${repeat} - behaves as native`, function () {
          const arr = init.slice();
          const expectedArr = init.slice();
          sut = new ArrayObserver(arr);
          let expectedResult;
          let actualResult;
          let i = 0;
          while (i < repeat) {
            expectedResult = expectedArr.pop();
            actualResult = arr.pop();
            assert.strictEqual(actualResult, expectedResult, `actualResult`);
            assert.deepStrictEqual(arr, expectedArr);
            i++;
          }
        });

        it(`size=${padRight(init.length, 2)} repeat=${repeat} - tracks changes`, function () {
          const arr = init.slice();
          const copy = init.slice();
          sut = new ArrayObserver(arr);
          sut.subscribe(new SynchronizingCollectionSubscriber(copy, arr));
          let i = 0;
          while (i < repeat) {
            arr.pop();
            assert.deepStrictEqual(copy, arr);
            i++;
          }
        });
      },
    );
  });

  describe(`observeShift`, function () {
    const initArr = [[], [1], [1, 2]];
    const repeatArr = [1, 2, 3, 4];

    eachCartesianJoin(
      [
        initArr,
        repeatArr,
      ],
      function (init, repeat) {
        it(`size=${padRight(init.length, 2)} repeat=${repeat} - behaves as native`, function () {
          const arr = init.slice();
          const expectedArr = init.slice();
          sut = new ArrayObserver(arr);
          let expectedResult;
          let actualResult;
          let i = 0;
          while (i < repeat) {
            expectedResult = expectedArr.shift();
            actualResult = arr.shift();
            assert.strictEqual(actualResult, expectedResult, `actualResult`);
            assert.deepStrictEqual(arr, expectedArr);
            i++;
          }
        });

        it(`size=${padRight(init.length, 2)} repeat=${repeat} - tracks changes`, function () {
          const arr = init.slice();
          const copy = init.slice();
          sut = new ArrayObserver(arr);
          sut.subscribe(new SynchronizingCollectionSubscriber(copy, arr));
          let i = 0;
          while (i < repeat) {
            arr.shift();
            assert.deepStrictEqual(copy, arr);
            i++;
          }
        });
      },
    );
  });

  describe(`observeSplice`, function () {
    const initArr = [[], [1], [1, 2]];
    const startArr = [undefined, -1, 0, 1, 2];
    const deleteCountArr = [undefined, -1, 0, 1, 2, 3];
    const itemsArr = [undefined, [], [1], [1, 2]];
    const repeatArr = [1, 2];

    eachCartesianJoin(
      [
        initArr,
        startArr,
        deleteCountArr,
        itemsArr,
        repeatArr,
      ],
      function (init, start, deleteCount, items, repeat) {
        it(`size=${padRight(init.length, 2)} start=${padRight(start, 9)} deleteCount=${padRight(deleteCount, 9)} itemCount=${padRight(items?.length, 9)} repeat=${repeat} - behaves as native`, function () {
          const arr = init.slice();
          const expectedArr = init.slice();
          const newItems = items?.slice();
          sut = new ArrayObserver(arr);
          let expectedResult;
          let actualResult;
          let i = 0;
          while (i < repeat) {
            incrementItems(newItems, i);
            if (items === undefined) {
              if (deleteCount === undefined) {
                if (start === undefined) {
                  expectedResult = (expectedArr as any).splice();
                  actualResult = (arr as any).splice();
                } else {
                  expectedResult = expectedArr.splice(start);
                  actualResult = arr.splice(start);
                }
              } else {
                expectedResult = expectedArr.splice(start, deleteCount);
                actualResult = arr.splice(start, deleteCount);
              }
            } else {
              expectedResult = expectedArr.splice(start, deleteCount, ...newItems);
              actualResult = arr.splice(start, deleteCount, ...newItems);
            }
            assert.deepStrictEqual(actualResult, expectedResult);
            assert.deepStrictEqual(arr, expectedArr);
            i++;
          }
        });

        it(`size=${padRight(init.length, 2)} start=${padRight(start, 9)} deleteCount=${padRight(deleteCount, 9)} itemCount=${padRight(items?.length, 9)} repeat=${repeat} - tracks changes`, function () {
          const arr = init.slice();
          const copy = init.slice();
          const newItems = items?.slice();
          sut = new ArrayObserver(arr);
          sut.subscribe(new SynchronizingCollectionSubscriber(copy, arr));
          let i = 0;
          while (i < repeat) {
            incrementItems(newItems, i);
            if (newItems === undefined) {
              if (deleteCount === undefined) {
                arr.splice(start);
              } else {
                arr.splice(start, deleteCount);
              }
            } else {
              arr.splice(start, deleteCount, ...newItems);
            }
            assert.deepStrictEqual(copy, arr);
            i++;
          }
        });
      },
    );
  });

  describe(`observeReverse`, function () {
    const initArr = [[], [1], [1, 2], [1, 2, 3], [1, 2, 3, 4]];
    const repeatArr = [1, 2];

    eachCartesianJoin(
      [
        initArr,
        repeatArr,
      ],
      function (init, repeat) {
        it(`size=${padRight(init.length, 2)} repeat=${repeat} - behaves as native`, function () {
          const arr = init.slice();
          const expectedArr = init.slice();
          sut = new ArrayObserver(arr);
          let expectedResult;
          let actualResult;
          let i = 0;
          while (i < repeat) {
            expectedResult = expectedArr.reverse();
            actualResult = arr.reverse();
            assert.deepStrictEqual(actualResult, expectedResult);
            assert.deepStrictEqual(arr, expectedArr);
            i++;
          }
        });

        it(`size=${padRight(init.length, 2)} repeat=${repeat} - tracks changes`, function () {
          const arr = init.slice();
          const copy = init.slice();
          sut = new ArrayObserver(arr);
          sut.subscribe(new SynchronizingCollectionSubscriber(copy, arr));
          let i = 0;
          while (i < repeat) {
            arr.reverse();
            assert.deepStrictEqual(copy, arr);
            i++;
          }
        });
      },
    );
  });

  describe(`observeSort`, function () {
    const arraySizes = [0, 1, 2, 3, 5, 10, 50];
    const types = ['undefined', 'null', 'boolean', 'string', 'number', 'object', 'mixed'];
    const compareFns = [
      undefined,
      (a, b) => a - b,
      (a, b) => a === b ? 0 : a - b,
      (a, b) => a === b ? 0 : a < b ? -1 : 1
    ];
    const reverseOrNot = [true, false];
    for (const arraySize of arraySizes) {
      const getNumber = getNumberFactory(arraySize);
      const init = new Array(arraySize);

      for (const type of types) {
        const getValue = getValueFactory(getNumber, type, types);
        let i = 0;
        while (i < arraySize) {
          init[i] = getValue(i);
          i++;
        }

        for (const reverse of reverseOrNot) {
          if (reverse) {
            init.reverse();
          }
          for (const compareFn of compareFns) {
            it(`size=${padRight(init.length, 4)} type=${padRight(type, 9)} reverse=${padRight(reverse, 5)} sortFunc=${compareFn} - behaves as native`, function () {
              const arr = init.slice();
              const expectedArr = init.slice();
              sut = new ArrayObserver(arr);
              const expectedResult = expectedArr.sort(compareFn);
              const actualResult = arr.sort(compareFn);
              assert.strictEqual(expectedResult, expectedArr, `expectedResult`);
              assert.strictEqual(actualResult, arr, `actualResult`);
              try {
                assert.deepStrictEqual(arr, expectedArr);
              } catch (e) {
                if (compareFn !== undefined) {
                  // a browser may wrap a custom sort function to normalize the results
                  // so don't consider this a failed test, but just warn so we know about it
                  let differences = 0;
                  let i2 = 0;
                  while (i2 < arraySize) {
                    if (arr[i2] !== expectedArr[i2]) {
                      differences++;
                    }
                    i2++;
                  }
                  console.warn(`${differences} of the ${arraySize} '${type}' items had a different position after sorting with ${compareFn}`);
                } else {
                  throw e;
                }
              }
            });

            it(`size=${padRight(init.length, 4)} type=${padRight(type, 9)} reverse=${padRight(reverse, 5)} sortFunc=${compareFn} - tracks changes`, function () {
              const arr = init.slice();
              const copy = init.slice();
              sut = new ArrayObserver(arr);
              sut.subscribe(new SynchronizingCollectionSubscriber(copy, arr));
              arr.sort(compareFn);
              assert.deepStrictEqual(copy, arr);
            });
          }
        }
      }
    }
  });
});

// function padLeft(input: unknown, len: number): string {
//   const str = `${input}`;
//   return new Array(len - str.length + 1).join(' ') + str;
// }

function padRight(input: unknown, len: number): string {
  const str = `${input}`;
  return str + new Array(len - str.length + 1).join(' ');
}

function getNumberFactory(arraySize: number) {
  const middle = (arraySize / 2) | 0;
  return (i) => {
    return i < middle ? arraySize - i : i;
  };
}

function getValueFactory(getNumber: (i: number) => unknown, type: string, types: string[]): (i: number) => unknown {
  let factories: ((i: number) => unknown)[];
  switch (type) {
    case 'undefined':
      return () => undefined;
    case 'null':
      return () => null;
    case 'boolean':
      return (i) => i % 2 === 0;
    case 'string':
      return (i) => (getNumber(i) as object).toString();
    case 'number':
      return getNumber;
    case 'object':
      // eslint-disable-next-line @typescript-eslint/no-unused-expressions
      return (i) => { [getNumber(i)]; };
    case 'mixed':
      factories = [
        getValueFactory(getNumber, types[0], types),
        getValueFactory(getNumber, types[1], types),
        getValueFactory(getNumber, types[2], types),
        getValueFactory(getNumber, types[3], types),
        getValueFactory(getNumber, types[4], types),
        getValueFactory(getNumber, types[5], types)
      ];
      return (i) => factories[i % 6](i);
  }
}

function incrementItems(items: number[], by: number): void {
  if (items === undefined) {
    return;
  }
  let i = 0;
  const len = items.length;
  while (i < len) {
    if (typeof items[i] === 'number') {
      items[i] += by;
    }
    i++;
  }
}