https://github.com/angular/angular
Raw File
Tip revision: 1fb0dbb30061b7cb84a275a18110f3eb9a107771 authored by Andrew Kushnir on 10 March 2021, 18:03:45 UTC
release: cut the v12.0.0-next.4 release (#41156)
Tip revision: 1fb0dbb
scroll-spy.service.spec.ts
import { Injector } from '@angular/core';
import { fakeAsync, tick } from '@angular/core/testing';
import { DOCUMENT } from '@angular/common';

import { ScrollService } from 'app/shared/scroll.service';
import { ScrollItem, ScrollSpiedElement, ScrollSpiedElementGroup, ScrollSpyService } from 'app/shared/scroll-spy.service';


describe('ScrollSpiedElement', () => {
  it('should expose the spied element and index', () => {
    const elem = {} as Element;
    const spiedElem = new ScrollSpiedElement(elem, 42);

    expect(spiedElem.element).toBe(elem);
    expect(spiedElem.index).toBe(42);
  });

  describe('#calculateTop()', () => {
    it('should calculate the `top` value', () => {
      const elem = {getBoundingClientRect: () => ({top: 100})} as Element;
      const spiedElem = new ScrollSpiedElement(elem, 42);

      spiedElem.calculateTop(0, 0);
      expect(spiedElem.top).toBe(100);

      spiedElem.calculateTop(20, 0);
      expect(spiedElem.top).toBe(120);

      spiedElem.calculateTop(0, 10);
      expect(spiedElem.top).toBe(90);

      spiedElem.calculateTop(20, 10);
      expect(spiedElem.top).toBe(110);
    });
  });
});


describe('ScrollSpiedElementGroup', () => {
  describe('#calibrate()', () => {
    it('should calculate `top` for all spied elements', () => {
      const spy = spyOn(ScrollSpiedElement.prototype, 'calculateTop');
      const elems = [{}, {}, {}] as Element[];
      const group = new ScrollSpiedElementGroup(elems);

      expect(spy).not.toHaveBeenCalled();

      group.calibrate(20, 10);
      const callInfo = spy.calls.all();

      expect(spy).toHaveBeenCalledTimes(3);
      expect(callInfo[0].object.index).toBe(0);
      expect(callInfo[1].object.index).toBe(1);
      expect(callInfo[2].object.index).toBe(2);
      expect(callInfo[0].args).toEqual([20, 10]);
      expect(callInfo[1].args).toEqual([20, 10]);
      expect(callInfo[2].args).toEqual([20, 10]);
    });
  });

  describe('#onScroll()', () => {
    let group: ScrollSpiedElementGroup;
    let activeItems: (ScrollItem|null)[];

    const activeIndices = () => activeItems.map(x => x && x.index);

    beforeEach(() => {
      const tops = [50, 150, 100];

      spyOn(ScrollSpiedElement.prototype, 'calculateTop').and.callFake(
        function(this: ScrollSpiedElement) {
        this.top = tops[this.index];
      });

      activeItems = [];
      group = new ScrollSpiedElementGroup([{}, {}, {}] as Element[]);
      group.activeScrollItem.subscribe(item => activeItems.push(item));
      group.calibrate(20, 10);
    });


    it('should emit a `ScrollItem` on `activeScrollItem`', () => {
      expect(activeItems.length).toBe(0);

      group.onScroll(20, 140);
      expect(activeItems.length).toBe(1);

      group.onScroll(20, 140);
      expect(activeItems.length).toBe(2);
    });

    it('should emit the lower-most element that is above `scrollTop`', () => {
      group.onScroll(45, 200);
      group.onScroll(55, 200);
      expect(activeIndices()).toEqual([null, 0]);

      activeItems.length = 0;
      group.onScroll(95, 200);
      group.onScroll(105, 200);
      expect(activeIndices()).toEqual([0, 2]);

      activeItems.length = 0;
      group.onScroll(145, 200);
      group.onScroll(155, 200);
      expect(activeIndices()).toEqual([2, 1]);

      activeItems.length = 0;
      group.onScroll(75, 200);
      group.onScroll(175, 200);
      group.onScroll(125, 200);
      group.onScroll(25, 200);
      expect(activeIndices()).toEqual([0, 1, 2, null]);
    });

    it('should always emit the lower-most element if scrolled to the bottom', () => {
      group.onScroll(140, 140);
      group.onScroll(145, 140);
      group.onScroll(138.5, 140);
      group.onScroll(139.5, 140);

      expect(activeIndices()).toEqual([1, 1, 2, 1]);
    });

    it('should emit null if all elements are below `scrollTop`', () => {
      group.onScroll(0, 140);
      expect(activeItems).toEqual([null]);

      group.onScroll(49, 140);
      expect(activeItems).toEqual([null, null]);
    });

    it('should emit null if there are no spied elements (even if scrolled to the bottom)', () => {
      group = new ScrollSpiedElementGroup([]);
      group.activeScrollItem.subscribe(item => activeItems.push(item));

      group.onScroll(20, 140);
      expect(activeItems).toEqual([null]);

      group.onScroll(140, 140);
      expect(activeItems).toEqual([null, null]);

      group.onScroll(145, 140);
      expect(activeItems).toEqual([null, null, null]);
    });
  });
});


describe('ScrollSpyService', () => {
  let injector: Injector;
  let scrollSpyService: ScrollSpyService;

  beforeEach(() => {
    injector = Injector.create({providers: [
      { provide: DOCUMENT, useValue: { body: {} } },
      { provide: ScrollService, useValue: { topOffset: 50 } },
      { provide: ScrollSpyService, deps: [DOCUMENT, ScrollService] }
    ]});

    scrollSpyService = injector.get(ScrollSpyService);
  });


  describe('#spyOn()', () => {
    let getSpiedElemGroups: () => ScrollSpiedElementGroup[];

    beforeEach(() => {
      getSpiedElemGroups = () => (scrollSpyService as any).spiedElementGroups;
    });


    it('should create a `ScrollSpiedElementGroup` when called', () => {
      expect(getSpiedElemGroups().length).toBe(0);

      scrollSpyService.spyOn([]);
      expect(getSpiedElemGroups().length).toBe(1);
    });

    it('should initialize the newly created `ScrollSpiedElementGroup`', () => {
      const calibrateSpy = spyOn(ScrollSpiedElementGroup.prototype, 'calibrate');
      const onScrollSpy = spyOn(ScrollSpiedElementGroup.prototype, 'onScroll');

      scrollSpyService.spyOn([]);
      expect(calibrateSpy).toHaveBeenCalledTimes(1);
      expect(onScrollSpy).toHaveBeenCalledTimes(1);

      scrollSpyService.spyOn([]);
      expect(calibrateSpy).toHaveBeenCalledTimes(2);
      expect(onScrollSpy).toHaveBeenCalledTimes(2);
    });

    it('should call `onResize()` if it is the first `ScrollSpiedElementGroup`', () => {
      const actions: string[] = [];

      const onResizeSpy = spyOn(ScrollSpyService.prototype as any, 'onResize')
                          .and.callFake(() => actions.push('onResize'));
      const calibrateSpy = spyOn(ScrollSpiedElementGroup.prototype, 'calibrate')
                           .and.callFake(() => actions.push('calibrate'));

      expect(onResizeSpy).not.toHaveBeenCalled();
      expect(calibrateSpy).not.toHaveBeenCalled();

      scrollSpyService.spyOn([]);
      expect(actions).toEqual(['onResize', 'calibrate']);

      scrollSpyService.spyOn([]);
      expect(actions).toEqual(['onResize', 'calibrate', 'calibrate']);
    });

    it('should forward `ScrollSpiedElementGroup#activeScrollItem` as `active`', () => {
      const activeIndices1: (number | null)[] = [];
      const activeIndices2: (number | null)[] = [];

      const info1 = scrollSpyService.spyOn([]);
      const info2 = scrollSpyService.spyOn([]);
      const spiedElemGroups = getSpiedElemGroups();

      info1.active.subscribe(item => activeIndices1.push(item && item.index));
      info2.active.subscribe(item => activeIndices2.push(item && item.index));
      activeIndices1.length = 0;
      activeIndices2.length = 0;

      spiedElemGroups[0].activeScrollItem.next({index: 1} as ScrollItem);
      spiedElemGroups[0].activeScrollItem.next({index: 2} as ScrollItem);
      spiedElemGroups[1].activeScrollItem.next({index: 3} as ScrollItem);
      spiedElemGroups[0].activeScrollItem.next(null);
      spiedElemGroups[1].activeScrollItem.next({index: 4} as ScrollItem);
      spiedElemGroups[1].activeScrollItem.next(null);
      spiedElemGroups[0].activeScrollItem.next({index: 5} as ScrollItem);
      spiedElemGroups[1].activeScrollItem.next({index: 6} as ScrollItem);

      expect(activeIndices1).toEqual([1, 2, null, 5]);
      expect(activeIndices2).toEqual([3, 4, null, 6]);
    });

    it('should remember and emit the last active item to new subscribers', () => {
      const items = [{index: 1}, {index: 2}, {index: 3}] as ScrollItem[];
      let lastActiveItem = null as unknown as ScrollItem|null;

      const info = scrollSpyService.spyOn([]);
      const spiedElemGroup = getSpiedElemGroups()[0];

      spiedElemGroup.activeScrollItem.next(items[0]);
      spiedElemGroup.activeScrollItem.next(items[1]);
      spiedElemGroup.activeScrollItem.next(items[2]);
      spiedElemGroup.activeScrollItem.next(null);
      spiedElemGroup.activeScrollItem.next(items[1]);
      info.active.subscribe(item => lastActiveItem = item);

      expect(lastActiveItem).toBe(items[1]);

      spiedElemGroup.activeScrollItem.next(null);
      info.active.subscribe(item => lastActiveItem = item);

      expect(lastActiveItem).toBeNull();
    });

    it('should only emit distinct values on `active`', () => {
      const items = [{index: 1}, {index: 2}] as ScrollItem[];
      const activeIndices: (number | null)[] = [];

      const info = scrollSpyService.spyOn([]);
      const spiedElemGroup = getSpiedElemGroups()[0];

      info.active.subscribe(item => activeIndices.push(item && item.index));
      activeIndices.length = 0;

      spiedElemGroup.activeScrollItem.next(items[0]);
      spiedElemGroup.activeScrollItem.next(items[0]);
      spiedElemGroup.activeScrollItem.next(items[1]);
      spiedElemGroup.activeScrollItem.next(items[1]);
      spiedElemGroup.activeScrollItem.next(null);
      spiedElemGroup.activeScrollItem.next(null);
      spiedElemGroup.activeScrollItem.next(items[0]);
      spiedElemGroup.activeScrollItem.next(items[1]);
      spiedElemGroup.activeScrollItem.next(null);

      expect(activeIndices).toEqual([1, 2, null, 1, 2, null]);
    });

    it('should remove the corresponding `ScrollSpiedElementGroup` when calling `unspy()`', () => {
      const info1 = scrollSpyService.spyOn([]);
      const info2 = scrollSpyService.spyOn([]);
      const info3 = scrollSpyService.spyOn([]);
      const groups = getSpiedElemGroups().slice();

      expect(getSpiedElemGroups()).toEqual(groups);

      info2.unspy();
      expect(getSpiedElemGroups()).toEqual([groups[0], groups[2]]);

      info1.unspy();
      expect(getSpiedElemGroups()).toEqual([groups[2]]);

      info3.unspy();
      expect(getSpiedElemGroups()).toEqual([]);
    });
  });

  describe('window resize events', () => {
    const RESIZE_EVENT_DELAY = 300;
    let onResizeSpy: jasmine.Spy;

    beforeEach(() => {
      onResizeSpy = spyOn(ScrollSpyService.prototype as any, 'onResize');
    });


    it('should be subscribed to when the first group of elements is spied on', fakeAsync(() => {
      window.dispatchEvent(new Event('resize'));
      expect(onResizeSpy).not.toHaveBeenCalled();

      scrollSpyService.spyOn([]);
      onResizeSpy.calls.reset();

      window.dispatchEvent(new Event('resize'));
      expect(onResizeSpy).not.toHaveBeenCalled();

      tick(RESIZE_EVENT_DELAY);
      expect(onResizeSpy).toHaveBeenCalled();
    }));

    it('should be unsubscribed from when the last group of elements is removed', fakeAsync(() => {
      const info1 = scrollSpyService.spyOn([]);
      const info2 = scrollSpyService.spyOn([]);
      onResizeSpy.calls.reset();

      window.dispatchEvent(new Event('resize'));
      tick(RESIZE_EVENT_DELAY);
      expect(onResizeSpy).toHaveBeenCalled();

      info1.unspy();
      onResizeSpy.calls.reset();

      window.dispatchEvent(new Event('resize'));
      tick(RESIZE_EVENT_DELAY);
      expect(onResizeSpy).toHaveBeenCalled();

      info2.unspy();
      onResizeSpy.calls.reset();

      window.dispatchEvent(new Event('resize'));
      tick(RESIZE_EVENT_DELAY);
      expect(onResizeSpy).not.toHaveBeenCalled();
    }));

    it(`should only fire every ${RESIZE_EVENT_DELAY}ms`, fakeAsync(() => {
      scrollSpyService.spyOn([]);
      onResizeSpy.calls.reset();

      window.dispatchEvent(new Event('resize'));
      tick(RESIZE_EVENT_DELAY - 2);
      expect(onResizeSpy).not.toHaveBeenCalled();

      window.dispatchEvent(new Event('resize'));
      tick(1);
      expect(onResizeSpy).not.toHaveBeenCalled();

      window.dispatchEvent(new Event('resize'));
      tick(1);
      expect(onResizeSpy).toHaveBeenCalledTimes(1);

      onResizeSpy.calls.reset();
      tick(RESIZE_EVENT_DELAY / 2);

      window.dispatchEvent(new Event('resize'));
      tick(RESIZE_EVENT_DELAY - 2);
      expect(onResizeSpy).not.toHaveBeenCalled();

      window.dispatchEvent(new Event('resize'));
      tick(1);
      expect(onResizeSpy).not.toHaveBeenCalled();

      window.dispatchEvent(new Event('resize'));
      tick(1);
      expect(onResizeSpy).toHaveBeenCalledTimes(1);
    }));
  });

  describe('window scroll events', () => {
    const SCROLL_EVENT_DELAY = 10;
    let onScrollSpy: jasmine.Spy;

    beforeEach(() => {
      onScrollSpy = spyOn(ScrollSpyService.prototype as any, 'onScroll');
    });


    it('should be subscribed to when the first group of elements is spied on', fakeAsync(() => {
      window.dispatchEvent(new Event('scroll'));
      expect(onScrollSpy).not.toHaveBeenCalled();

      scrollSpyService.spyOn([]);

      window.dispatchEvent(new Event('scroll'));
      expect(onScrollSpy).not.toHaveBeenCalled();

      tick(SCROLL_EVENT_DELAY);
      expect(onScrollSpy).toHaveBeenCalled();
    }));

    it('should be unsubscribed from when the last group of elements is removed', fakeAsync(() => {
      const info1 = scrollSpyService.spyOn([]);
      const info2 = scrollSpyService.spyOn([]);

      window.dispatchEvent(new Event('scroll'));
      tick(SCROLL_EVENT_DELAY);
      expect(onScrollSpy).toHaveBeenCalled();

      info1.unspy();
      onScrollSpy.calls.reset();

      window.dispatchEvent(new Event('scroll'));
      tick(SCROLL_EVENT_DELAY);
      expect(onScrollSpy).toHaveBeenCalled();

      info2.unspy();
      onScrollSpy.calls.reset();

      window.dispatchEvent(new Event('scroll'));
      tick(SCROLL_EVENT_DELAY);
      expect(onScrollSpy).not.toHaveBeenCalled();
    }));

    it(`should only fire every ${SCROLL_EVENT_DELAY}ms`, fakeAsync(() => {
      scrollSpyService.spyOn([]);

      window.dispatchEvent(new Event('scroll'));
      tick(SCROLL_EVENT_DELAY - 2);
      expect(onScrollSpy).not.toHaveBeenCalled();

      window.dispatchEvent(new Event('scroll'));
      tick(1);
      expect(onScrollSpy).not.toHaveBeenCalled();

      window.dispatchEvent(new Event('scroll'));
      tick(1);
      expect(onScrollSpy).toHaveBeenCalledTimes(1);

      onScrollSpy.calls.reset();
      tick(SCROLL_EVENT_DELAY / 2);

      window.dispatchEvent(new Event('scroll'));
      tick(SCROLL_EVENT_DELAY - 2);
      expect(onScrollSpy).not.toHaveBeenCalled();

      window.dispatchEvent(new Event('scroll'));
      tick(1);
      expect(onScrollSpy).not.toHaveBeenCalled();

      window.dispatchEvent(new Event('scroll'));
      tick(1);
      expect(onScrollSpy).toHaveBeenCalledTimes(1);
    }));
  });

  describe('#onResize()', () => {
    it('should re-calibrate each `ScrollSpiedElementGroup`', () => {
      scrollSpyService.spyOn([]);
      scrollSpyService.spyOn([]);
      scrollSpyService.spyOn([]);

      const spiedElemGroups: ScrollSpiedElementGroup[] = (scrollSpyService as any).spiedElementGroups;
      const calibrateSpies = spiedElemGroups.map(group => spyOn(group, 'calibrate'));

      calibrateSpies.forEach(spy => expect(spy).not.toHaveBeenCalled());

      (scrollSpyService as any).onResize();
      calibrateSpies.forEach(spy => expect(spy).toHaveBeenCalled());
    });
  });

  describe('#onScroll()', () => {
    it('should propagate to each `ScrollSpiedElementGroup`', () => {
      scrollSpyService.spyOn([]);
      scrollSpyService.spyOn([]);
      scrollSpyService.spyOn([]);

      const spiedElemGroups: ScrollSpiedElementGroup[] = (scrollSpyService as any).spiedElementGroups;
      const onScrollSpies = spiedElemGroups.map(group => spyOn(group, 'onScroll'));

      onScrollSpies.forEach(spy => expect(spy).not.toHaveBeenCalled());

      (scrollSpyService as any).onScroll();
      onScrollSpies.forEach(spy => expect(spy).toHaveBeenCalled());
    });

    it('should first re-calibrate if the content height has changed', () => {
      const body = injector.get(DOCUMENT).body as any;

      scrollSpyService.spyOn([]);
      scrollSpyService.spyOn([]);
      scrollSpyService.spyOn([]);

      const spiedElemGroups: ScrollSpiedElementGroup[] = (scrollSpyService as any).spiedElementGroups;
      const onScrollSpies = spiedElemGroups.map(group => spyOn(group, 'onScroll'));
      const calibrateSpies = spiedElemGroups.map((group, i) => spyOn(group, 'calibrate')
                .and.callFake(() => expect(onScrollSpies[i]).not.toHaveBeenCalled()));

      calibrateSpies.forEach(spy => expect(spy).not.toHaveBeenCalled());
      onScrollSpies.forEach(spy => expect(spy).not.toHaveBeenCalled());

      // No content height change...
      (scrollSpyService as any).onScroll();
      calibrateSpies.forEach(spy => expect(spy).not.toHaveBeenCalled());
      onScrollSpies.forEach(spy => expect(spy).toHaveBeenCalled());

      onScrollSpies.forEach(spy => spy.calls.reset());
      body.scrollHeight = 100;

      // Viewport changed...
      (scrollSpyService as any).onScroll();
      calibrateSpies.forEach(spy => expect(spy).toHaveBeenCalled());
      onScrollSpies.forEach(spy => expect(spy).toHaveBeenCalled());
    });
  });
});
back to top