Repository: exadel-inc/esl
Pull request #1325 (2022-11-06 10:54:11)

test(esl-animate): cover esl-animate module parts with tests and code improvements
Tests are covering the following areas of module:
ESL Animate Service smoke (basic + options)
ESL Animate Element unit (wrapper) tests
ESL Animate Mixin unit (wrapper) tests
#1280


Patch:
diff --git a/src/modules/esl-animate/core/esl-animate-service.ts b/src/modules/esl-animate/core/esl-animate-service.ts
index cc12efd4b..712ec4a9a 100644
--- a/src/modules/esl-animate/core/esl-animate-service.ts
+++ b/src/modules/esl-animate/core/esl-animate-service.ts
@@ -145,10 +145,7 @@ export class ESLAnimateService {
     CSSClassUtils.add(item, config.cls);
     this._entries.delete(item);
 
-    if (config._unsubscribe) {
-      this._io.unobserve(item);
-      this._configMap.delete(item);
-    }
+    if (config._unsubscribe) this.unobserve(item);
   }
 
   /** Returns config */


Test patch:
diff --git a/src/modules/esl-animate/test/animate.mixin.test.ts b/src/modules/esl-animate/test/animate.mixin.test.ts
new file mode 100644
index 000000000..4fa41eb63
--- /dev/null
+++ b/src/modules/esl-animate/test/animate.mixin.test.ts
@@ -0,0 +1,40 @@
+import {ESLAnimateMixin, ESLAnimateService} from '../core';
+
+jest.mock('../core/esl-animate-service', () => ({
+  ESLAnimateService: {
+    observe: jest.fn(),
+    unobserve: jest.fn()
+  }
+}));
+
+jest.mock('../../esl-utils/dom/ready', () => ({
+  onDocumentReady: (cb: any) => cb()
+}));
+
+describe('ESLAnimateMixin', () => {
+  const $el = document.createElement('div');
+  $el.toggleAttribute(ESLAnimateMixin.is, true);
+  document.body.appendChild($el);
+
+  ESLAnimateMixin.register();
+  const mixin = ESLAnimateMixin.get($el) as ESLAnimateMixin;
+
+  afterEach(() => jest.clearAllMocks());
+
+  test('ESLAnimateMixin instance', () => {
+    expect(mixin).toBeInstanceOf(ESLAnimateMixin);
+    expect(ESLAnimateService.observe).toBeCalledWith($el, {cls: 'in', force: true, ratio: 0.4, repeat: false});
+  });
+
+  test('manual reanimate call', () => {
+    mixin.options = {repeat: true};
+    mixin.reanimate();
+    expect(ESLAnimateService.observe).toBeCalledWith($el, {cls: 'in', force: true, ratio: 0.4, repeat: true});
+  });
+
+  test('disconnected callback', async () => {
+    $el.toggleAttribute(ESLAnimateMixin.is);
+    await Promise.resolve();
+    expect(ESLAnimateService.unobserve).toBeCalled();
+  });
+});
diff --git a/src/modules/esl-animate/test/animate.service.test.ts b/src/modules/esl-animate/test/animate.service.test.ts
new file mode 100644
index 000000000..b50f293ba
--- /dev/null
+++ b/src/modules/esl-animate/test/animate.service.test.ts
@@ -0,0 +1,278 @@
+import {ESLAnimateService} from '../core';
+import {CSSClassUtils} from '../../esl-utils/dom/class';
+import {IntersectionObserverMock, triggerIntersection} from './intersectionObserver.mock';
+
+describe('ESLAnimateService', () => {
+  beforeAll(() => {
+    window.IntersectionObserver = jest.fn((cb) => new IntersectionObserverMock(cb));
+    jest.useFakeTimers();
+  });
+
+  afterAll(() => {
+    jest.clearAllMocks();
+    jest.useRealTimers();
+  });
+
+  describe('Default ESLAnimateService config', () => {
+    describe('Static class methods', () => {
+      test('intersection is detected', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el);
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('intersection is detected, but with ration less than 1%', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el);
+
+        triggerIntersection(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(false);
+      });
+
+      test('intersection is triggered only once', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el, {repeat: false});
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+
+        triggerIntersection(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('intersection of multiple elements is detected', () => {
+        const el = document.createElement('div');
+        const el2 = document.createElement('div');
+        ESLAnimateService.observe([el, el2]);
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        triggerIntersection(el2, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+        expect(CSSClassUtils.has(el2, 'in')).toBe(true);
+      });
+
+      test('element is observed for intersections', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el);
+        expect(ESLAnimateService.isObserved(el)).toBe(true);
+      });
+
+      test('element gets unobserved and intersection isn`t triggered', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el);
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        ESLAnimateService.unobserve(el);
+
+        triggerIntersection(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('debounced animation process isn`t triggered if it gets unobserved in process', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el);
+        ESLAnimateService.unobserve(el);
+        expect(ESLAnimateService.isObserved(el)).toBe(false);
+      });
+    });
+
+    describe('Instance methods', () => {
+      test('intersection is detected', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el);
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('intersection is detected, but with ration less than 1%', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el);
+
+        triggerIntersection(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(false);
+      });
+
+      test('intersection is triggered only once', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el, {repeat: false});
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+
+        triggerIntersection(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('automatic unobservation after on second intersection', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el);
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+
+        triggerIntersection(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('element gets unobserved and intersection isn`t triggered', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el);
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        service.unobserve(el);
+
+        triggerIntersection(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('debounced animation process isn`t triggered if it gets unobserved in process', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el);
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        service.unobserve(el);
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(false);
+      });
+    });
+  });
+
+  describe('Custom ESLAnimateService config', () => {
+    test('intersection is triggered multiple times', () => {
+      const el = document.createElement('div');
+      ESLAnimateService.observe(el, {repeat: true});
+
+      triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+      jest.advanceTimersByTime(100);
+
+      triggerIntersection(el, {intersectionRatio: 0, isIntersecting: false});
+      jest.advanceTimersByTime(100);
+      expect(CSSClassUtils.has(el, 'in')).toBe(false);
+
+      triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+      jest.advanceTimersByTime(100);
+      expect(CSSClassUtils.has(el, 'in')).toBe(true);
+    });
+
+    test('custom class is set', () => {
+      const el = document.createElement('div');
+      ESLAnimateService.observe(el, {cls: 'customclass'});
+
+      triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+      jest.advanceTimersByTime(100);
+      expect(CSSClassUtils.has(el, 'customclass')).toBe(true);
+    });
+
+    test('observe even if class is present', () => {
+      const el = document.createElement('div');
+      el.classList.add('in');
+      ESLAnimateService.observe(el, {force: true});
+      expect(CSSClassUtils.has(el, 'in')).toBe(false);
+
+      triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+      jest.advanceTimersByTime(100);
+      expect(CSSClassUtils.has(el, 'in')).toBe(true);
+    });
+
+    describe('Intersection ratio', () => {
+      test('intersection isn`t triggered until required visibility ratio', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el, {ratio: 0.2});
+
+        triggerIntersection(el, {intersectionRatio: 0.1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(false);
+      });
+
+      test('intersection is triggered on required visibility ratio', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el, {ratio: 0.2});
+
+        triggerIntersection(el, {intersectionRatio: 0.2, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('intersection is triggered after required visibility ratio', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el, {ratio: 0.2});
+
+        triggerIntersection(el, {intersectionRatio: 0.4, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+    });
+
+    describe('Group delay', () => {
+      test('additional delay added to each next element', () => {
+        const el = document.createElement('div');
+        const el2 = document.createElement('div');
+        ESLAnimateService.observe([el, el2], {group: true, groupDelay: 100});
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        triggerIntersection(el2, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(101);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+        expect(CSSClassUtils.has(el2, 'in')).toBe(false);
+
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el2, 'in')).toBe(true);
+      });
+
+      test('groupDelay is cancelled if intersected again in the groupDelay timespan', () => {
+        const el = document.createElement('div');
+        const el2 = document.createElement('div');
+        ESLAnimateService.observe([el, el2], {group: true, groupDelay: 101});
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        triggerIntersection(el2, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        triggerIntersection(el2, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+        expect(CSSClassUtils.has(el2, 'in')).toBe(false);
+      });
+
+      test('groupDelay is cancelled if element is not intersected anymore in delay span', () => {
+        const el = document.createElement('div');
+        const el2 = document.createElement('div');
+        ESLAnimateService.observe([el, el2], {group: true, repeat: true, groupDelay: 101});
+
+        triggerIntersection(el, {intersectionRatio: 1, isIntersecting: true});
+        triggerIntersection(el2, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+
+        triggerIntersection(el, {intersectionRatio: 0, isIntersecting: false});
+        triggerIntersection(el2, {intersectionRatio: 0, isIntersecting: false});
+        expect(CSSClassUtils.has(el, 'in')).toBe(false);
+        expect(CSSClassUtils.has(el2, 'in')).toBe(false);
+      });
+    });
+  });
+});
diff --git a/src/modules/esl-animate/test/animate.test.ts b/src/modules/esl-animate/test/animate.test.ts
new file mode 100644
index 000000000..906997fca
--- /dev/null
+++ b/src/modules/esl-animate/test/animate.test.ts
@@ -0,0 +1,49 @@
+import {ESLAnimate, ESLAnimateService} from '../core';
+
+jest.mock('../core/esl-animate-service', () => ({
+  ESLAnimateService: {
+    observe: jest.fn(),
+    unobserve: jest.fn()
+  }
+}));
+
+describe('ESLAnimate element', () => {
+  const $el = ESLAnimate.create();
+  document.body.appendChild($el);
+
+  beforeAll(() => {
+    jest.useFakeTimers();
+    ESLAnimate.register();
+  });
+
+  afterAll(() => jest.useRealTimers());
+
+  afterEach(() => jest.clearAllMocks());
+
+  test('ESLAnimate instance', () => expect($el).toBeInstanceOf(ESLAnimate));
+
+  test('connected callback', () => {
+    expect($el.connected).toBe(false);
+    $el.repeat = true;
+    expect(ESLAnimateService.observe).not.toBeCalled();
+    jest.advanceTimersByTime(1);
+    expect(ESLAnimateService.observe).toBeCalledWith([$el], {cls: 'in', force: true, group: false, groupDelay: 100, ratio: 0.4, repeat: true});
+  });
+
+  test('attribute change callback', () => {
+    $el.repeat = true;
+    expect(ESLAnimateService.unobserve).toBeCalled();
+    expect(ESLAnimateService.observe).toBeCalled();
+  });
+
+  test('reanimate call', () => {
+    $el.reanimate();
+    expect(ESLAnimateService.observe).toBeCalled();
+  });
+
+  test('disconnected callback', () => {
+    $el.remove();
+    jest.advanceTimersByTime(1);
+    expect(ESLAnimateService.unobserve).toBeCalled();
+  });
+});
diff --git a/src/modules/esl-animate/test/intersectionObserver.mock.ts b/src/modules/esl-animate/test/intersectionObserver.mock.ts
new file mode 100644
index 000000000..de2442d98
--- /dev/null
+++ b/src/modules/esl-animate/test/intersectionObserver.mock.ts
@@ -0,0 +1,37 @@
+import {ESLEventUtils} from '../../esl-event-listener/core/api';
+import {bind} from '../../esl-utils/decorators';
+
+export function triggerIntersection($el: Element, cfg: Partial<IntersectionObserverEntryInit>): void {
+  ESLEventUtils.dispatch($el, 'intersection', {detail: cfg});
+}
+
+export class IntersectionObserverMock {
+
+  public constructor(public callback: IntersectionObserverCallback) {}
+
+  public root: Document | Element | null = null;
+
+  public rootMargin: string = '';
+
+  public get thresholds(): number[] {
+    return [];
+  }
+
+  public takeRecords(): any {}
+
+  @bind
+  private _onObserve(e: any): void {
+    const {intersectionRatio, isIntersecting} = e.detail;
+    this.callback([{intersectionRatio, target: e.target, isIntersecting}] as IntersectionObserverEntry[], this);
+  }
+
+  public observe(element: Element): void {
+    element.addEventListener('intersection', this._onObserve);
+  }
+
+  public unobserve(element: Element): void {
+    element.removeEventListener('intersection', this._onObserve);
+  }
+
+  public disconnect = jest.fn();
+}