scroll-spy.service.spec.ts
import { Injector, ReflectiveInjector } from '@angular/core';
import { fakeAsync, tick } from '@angular/core/testing';
import { DOCUMENT } from '@angular/platform-browser';
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').and.returnValue(0);
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, scrollTop: number, topOffset: number) {
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 = ReflectiveInjector.resolveAndCreate([
{ provide: DOCUMENT, useValue: { body: {} } },
{ provide: ScrollService, useValue: { topOffset: 50 } },
ScrollSpyService
]);
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: 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());
});
});
});