describe()

in spec/index.js [19:620]


describe('ListView', function () {
  let listView = null;

  function render() {
    return new Promise(resolve => listView.render(resolve));
  }

  beforeEach(function () {
    $('body').html(template(this.currentTest));
  });

  afterEach(function () {
    $('body').empty();
  });

  it('should be a Backbone View', function () {
    expect(ListView.prototype).is.instanceof(Backbone.View);
  });

  describe('Properties', function () {
    const count = 20000;

    const model = { title: 'Test Properties' };
    const listTemplate = alternativeListTemplate;
    const itemTemplate = item => `<li>${item.text}</li>`;
    const defaultItemHeight = 18;

    beforeEach(doAsync(async () => {
      listView = new ListView({
        el: '.test-container',
      }).set({
        items: _.map(_.range(count), i => ({ text: i })),
        listTemplate,
        model,
        itemTemplate,
        defaultItemHeight,
      });
      await render();
    }));

    afterEach(doAsync(async () => {
      listView.remove();
      await sleep(redrawInterval);
    }));

    it('should expose the length of the list', function () {
      expect(listView.length).to.equal(count);
    });

    it('should be able to get the items', function () {
      expect(listView.itemAt(10)).to.deep.equal({ text: 10 });
    });

    it('should be able to get the elements', function () {
      expect(listView.elementAt(10000)).to.be.null;
      expect(listView.elementAt(1)).to.be.an.instanceof(HTMLElement);
    });

    it('should expose the listTemplate', function () {
      expect(listView.listTemplate).to.equal(listTemplate);
    });

    it('should expose the model', function () {
      expect(listView.model).to.equal(model);
    });

    it('should expose the itemTemplate', function () {
      expect(listView.itemTemplate).to.equal(itemTemplate);
    });

    it('should expose the defaultItemHeight', function () {
      expect(listView.defaultItemHeight).to.equal(defaultItemHeight);
    });
  });

  describe('Handling viewport events', function () {
    const count = 20000;

    beforeEach(doAsync(async () => {
      listView = new ListView({
        el: '.test-container',
      }).set({
        items: _.map(_.range(count), i => ({ text: i })),
      });

      await render();
      listView.viewport.scrollTo({ y: 0 });
      await sleep(redrawInterval);
    }));

    afterEach(doAsync(async () => {
      listView.remove();
      await sleep(redrawInterval);
    }));

    it('should trigger redraw on viewport change', doAsync(async () => {
      const spy = sinon.spy();

      listView.once('didRedraw', spy);
      listView.viewport.trigger('change');
      expect(spy).not.to.be.called;

      await sleep(150);
      expect(spy).to.be.calledOnce;
    }));

    it('should block redraw for 0.2 second when press a key', doAsync(async () => {
      const spy = sinon.spy();

      listView.once('didRedraw', spy);
      listView.viewport.trigger('keypress');
      listView.viewport.trigger('change');
      expect(spy).not.to.be.called;

      await sleep(150);
      expect(spy).not.to.be.called;

      await sleep(150);
      expect(spy).to.be.calledOnce;
    }));
  });

  function viewportMetrics() {
    return listView.viewport.getMetrics();
  }

  function checkViewportFillup() {
    const items = $('.test-container > .internal-viewport > ul > li');
    const [rectFirst, rectLast] = [
      items.first(),
      items.last(),
    ].map($el => $el.get(0).getBoundingClientRect());
    const { top, bottom } = viewportMetrics().outer;

    if (listView.indexFirst > 0) {
      expect(rectFirst.top).to.be.at.most(top);
    }
    if (listView.indexLast < listView.options.items.length) {
      expect(rectLast.bottom).to.be.at.least(bottom);
    }

    return null;
  }

  function getElementRect(index) {
    expect(index).to.be.at.least(listView.indexFirst);
    expect(index).to.be.below(listView.indexLast);

    const el = $('.test-container > .internal-viewport > ul > li').get(index - listView.indexFirst);
    return el.getBoundingClientRect();
  }

  function checkItemLocation(index, position) {
    const rect = getElementRect(index);
    const { top, bottom } = viewportMetrics().outer;
    const middle = (top + bottom) / 2;

    if (position === 'top') {
      expect(Math.abs(rect.top - top)).to.be.below(1);
    } else if (position === 'bottom') {
      expect(Math.abs(rect.bottom - bottom)).to.be.below(1);
    } else if (position === 'middle') {
      const elMiddle = (rect.top + rect.bottom) / 2;
      expect(Math.abs(elMiddle - middle)).to.be.below(1);
    } else if (_.isNumber(position)) {
      expect(Math.abs(rect.top - (top + position))).to.be.below(1);
    }
  }

  function checkScrolledToTop() {
    const scrollTop = listView.viewport.getMetrics().scroll.y;

    expect(Math.abs(scrollTop)).to.be.at.most(1);
  }

  function checkScrolledToBottom() {
    const metrics = viewportMetrics();
    const scrollTopMax = metrics.inner.height - metrics.outer.height;
    const scrollTop = metrics.scroll.y;

    expect(scrollTop).to.be.at.least(scrollTopMax - 1);
  }

  function scrollToItem(...args) {
    return new Promise(resolve => listView.scrollToItem(...(args.concat([resolve]))));
  }

  function set(options) {
    return new Promise(resolve => listView.set(options, resolve));
  }

  function getTestCases(viewFactory) {
    return function () {
      beforeEach(doAsync(async () => {
        listView = viewFactory({ size: 20000 });
        await render();
        listView.viewport.scrollTo({ y: 0 });
        await sleep(redrawInterval);
      }));

      afterEach(doAsync(async () => {
        listView.remove();
        await sleep(redrawInterval);
      }));

      it('should create the ListView correctly', function () {
        expect($('.test-container').get(0)).to.equal(listView.el);
        expect($('.test-container > .internal-viewport > ul > li').length).to.be.above(0);
      });

      it('should fill up the viewport', function () {
        const elLast = $('.test-container > .internal-viewport > ul > li').last().get(0);
        const rectLast = elLast.getBoundingClientRect();
        const height = viewportMetrics().outer.height;

        expect(rectLast.bottom).to.be.at.least(height);
      });

      it('should fill up the viewport after jump scrolling', doAsync(async () => {
        for (let scrollTop of [1000, 2000, 20000, 10000]) {
          listView.viewport.scrollTo({ y: scrollTop });
          await sleep(redrawInterval);

          checkViewportFillup();
        }
      }));

      it('should fill up the viewport while scrolling down continuously', doAsync(async () => {
        for (let scrollTop = 1000; scrollTop < 1500; scrollTop += 100) {
          listView.viewport.scrollTo({ y: scrollTop });
          await sleep(redrawInterval);

          checkViewportFillup();
        }
      }));

      it('should fill up the viewport while scrolling up continuously', doAsync(async () => {
        for (let scrollTop = 2000; scrollTop > 1500; scrollTop -= 100) {
          listView.viewport.scrollTo({ y: scrollTop });
          await sleep(redrawInterval);

          checkViewportFillup();
        }
      }));

      it('should be able to scroll an element to top', doAsync(async () => {
        for (let index of [0, 1, 11, 111, 1111, 11111]) {
          await scrollToItem(index, 'top');

          checkItemLocation(index, 'top');
          checkViewportFillup();
        }

        await scrollToItem(listView.options.items.length - 1, 'top');

        checkScrolledToBottom();
        checkViewportFillup();
      }));

      it('should be able to scroll an element to bottom', doAsync(async () => {
        for (let index of [11111, 11110, 11100, 11000, 10000]) {
          await scrollToItem(index, 'bottom');

          checkItemLocation(index, 'bottom');
          checkViewportFillup();
        }

        await scrollToItem(0, 'bottom');

        checkScrolledToTop();
        checkViewportFillup();
      }));

      it('should be able to scroll an element to middle', doAsync(async () => {
        for (let index of [11111, 11110, 11100, 11000, 10000]) {
          await scrollToItem(index, 'middle');

          checkItemLocation(index, 'middle');
          checkViewportFillup();
        }

        await scrollToItem(0, 'middle');

        checkScrolledToTop();
        checkViewportFillup();

        await scrollToItem(listView.options.items.length - 1, 'middle');

        checkScrolledToBottom();
        checkViewportFillup();
      }));

      it('should be able to scroll an element to certain offset', doAsync(async () => {
        const index = 1000;
        const height = viewportMetrics().outer.height;

        for (let pos of [0, 0.2, 0.5, 0.7, 0.9].map(rate => rate * height)) {
          await scrollToItem(index, pos);

          checkItemLocation(index, pos);
          checkViewportFillup();
        }
      }));

      it('should be scroll item to nearest visible location with "default" option', doAsync(async () => {
        await scrollToItem(2000);
        checkItemLocation(2000, 'bottom');

        await scrollToItem(2001);
        checkItemLocation(2001, 'bottom');

        await scrollToItem(1000);
        checkItemLocation(1000, 'top');

        await scrollToItem(999);
        checkItemLocation(999, 'top');

        listView.scrollToItem(999);
        await sleep(redrawInterval);
        checkItemLocation(999, 'top');

        const top = getElementRect(1000).top;
        await scrollToItem(1000);
        expect(Math.abs(getElementRect(1000).top - top)).to.be.below(1);
      }));

      it('should complain about wrong position opitons', function () {
        _.each([
          true,
          'some-where',
          { foo: 'bar' },
          ['foo', 'bar'],
        ], pos => {
          expect(() => listView.scrollToItem(0, pos)).to.throw('Invalid position');
        });
      });

      it('should complain about the view is not rendered', function () {
        const view = viewFactory({ size: 20000 });
        const message = 'Cannot scroll before the view is rendered';
        expect(() => view.scrollToItem(10)).to.throw(message);
      });

      it('should be able to reset the defaultItemHeight', doAsync(async () => {
        const height = viewportMetrics().inner.height;
        await set({ defaultItemHeight: 22 });
        expect(viewportMetrics().inner.height).to.be.above(height);
      }));

      it('should be able to reset the items', doAsync(async () => {
        const $ul = $('.test-container > .internal-viewport > ul');
        const text = 'hello world!';

        await set({ items: [{ text }] });
        expect($ul.children().length).to.equal(3);
        expect($ul.children().text()).to.equal(text);

        await set({ items: [] });
        expect($ul.length).to.equal(1);
        expect($ul.children().length).to.equal(2);
      }));

      it('should be able to use duck typed array as items', doAsync(async () => {
        const $ul = $('.test-container > .internal-viewport > ul');
        const prefix = 'item';

        await set({
          items: {
            length: 50000,
            slice(start, stop) {
              return _.map(_.range(start, stop), i => ({ text: `${prefix} ${i}` }));
            },
          },
        });
        expect($ul.children().first().next().text()).to.equal(`${prefix} 0`);
        checkViewportFillup();
      }));

      it('should be able to reset the model and listTemplate', doAsync(async () => {
        const title = 'New Template';
        const model = { title };
        const listTemplate = alternativeListTemplate;

        await set({ model, listTemplate });

        const $h2 = $('.test-container > h2');
        expect($h2.length).to.equal(1);
        expect($h2.text()).to.equal(title);
        checkViewportFillup();
      }));

      it('should be able to reset the itemTemplate', doAsync(async () => {
        const prefix = 'item';
        const itemTemplate = ({ text }) => `<li>${prefix} - ${text}</li>`;

        await set({ itemTemplate });

        const $ul = $('.test-container > .internal-viewport > ul');
        expect($ul.children().length).to.be.at.least(3);
        expect($ul.children().first().next().text()).to.be.equal(`${prefix} - ${listView.itemAt(0).text}`);
        checkViewportFillup();
      }));

      it('should be able to handle the DOM events', doAsync(async () => {
        const spy = sinon.spy();
        const events = { 'click li': spy };

        await set({ events });

        const $ul = $('.test-container > .internal-viewport > ul');
        $ul.children().first().next().click();
        expect(spy).to.be.calledOnce;
      }));

      it('should be able to handle the ListView events', doAsync(async () => {
        const spyWillRedraw = sinon.spy();
        const spyDidRedraw = sinon.spy();
        const events = { willRedraw: spyWillRedraw, didRedraw: spyDidRedraw };

        await set({ events });
        expect(spyWillRedraw).have.been.calledOnce;
        expect(spyDidRedraw).have.been.calledOnce;

        await scrollToItem(1000);
        expect(spyWillRedraw).have.been.calledTwice;
        expect(spyDidRedraw).have.been.calledTwice;

        await set({ events: {} });
        expect(spyWillRedraw).have.been.calledTwice;
        expect(spyDidRedraw).have.been.calledTwice;

        await scrollToItem(0);
        expect(spyWillRedraw).have.been.calledTwice;
        expect(spyDidRedraw).have.been.calledTwice;
      }));

      it('should invoke the callback immediatedly if reset with no valid options', function () {
        const spy = sinon.spy();

        listView.set({ foo: 'bar' }, spy);
        expect(spy).to.be.calledOnce;
      });

      it('should be able to invalidate the rendered items', doAsync(async () => {
        const $ul = $('.test-container > .internal-viewport > ul');
        const elFirst = $ul.children().get(1);

        await new Promise(resolve => listView.invalidate(resolve));

        const elFirstNew = $ul.children().get(1);
        expect(elFirstNew).not.to.equal(elFirst);
      }));
    };
  }

  describe('with WindowViewport', getTestCases(({ size }) => new ListView({
    el: '.test-container',
  }).set({
    listTemplate: initialListTemplate,
    items: _.map(_.range(size), i => ({ text: i })),
  })));

  describe('with ElementViewport', getTestCases(({ size }) => {
    $('.test-container').css({
      height: 600,
      width: 400,
    });
    return new ListView({
      el: '.test-container',
    }).set({
      listTemplate: initialListTemplate,
      items: _.map(_.range(size), i => ({ text: i })),
    });
  }));

  describe('with variant height items', getTestCases(({ size }) => {
    $('.test-container').css({
      height: 500,
      width: 200,
    });
    return new ListView({
      el: '.test-container',
    }).set({
      listTemplate: initialListTemplate,
      items: _.map(_.range(size), i => ({
        text: `${i}: ${_.map(_.range(_.random(50)), () => _.random(9)).join('')}`,
      })),
    });
  }));

  describe('with internal viewport', getTestCases(({ size }) => new ListView({
    el: '.test-container',
    viewport: '.internal-viewport',
  }).set({
    model: { title: 'Internal Viewport' },
    listTemplate: initialListTemplate,
    items: _.map(_.range(size), i => ({ text: i })),
  })));

  describe('Non-virtualized list view', function () {
    const count = 500;

    beforeEach(doAsync(async () => {
      listView = new ListView({
        el: '.test-container',
        virtualized: false,
      }).set({
        listTemplate: initialListTemplate,
        items: _.map(_.range(count), i => ({ text: i })),
      });
      await render();
    }));

    afterEach(doAsync(async () => {
      listView.remove();
      await sleep(redrawInterval);
    }));

    async function checkDOMUnchanged(action) {
      const $ul = $('.test-container > .internal-viewport > ul');
      const elFirst = $ul.children().first().get(0);
      const elLast = $ul.children().last().get(0);

      await action(function () {
        const $ulNew = $('.test-container > .internal-viewport > ul');
        const elFirstNew = $ulNew.children().get(0);
        const elLastNew = $ulNew.children().last().get(0);

        expect($ulNew.get(0)).to.equal($ul.get(0));
        expect(elFirstNew).to.equal(elFirst);
        expect(elLastNew).to.equal(elLast);
      });
    }

    it('should render all items initially', function () {
      const $ul = $('.test-container > .internal-viewport > ul');

      expect($ul.children().length).to.equal(count + 2);
      expect($ul.children().first().next().text()).to.equal('0');
    });

    it('should keep the DOM unchanged after scrolling', doAsync(
      async () => checkDOMUnchanged(async verify => {
        for (let scrollTop of [100, 1000, 5000, 3000]) {
          listView.viewport.scrollTo({ y: scrollTop });
          await sleep(redrawInterval);
          verify();
        }

        for (let scrollTop = 1000; scrollTop < 1500; scrollTop += 100) {
          listView.viewport.scrollTo({ y: scrollTop });
          await sleep(redrawInterval);
          verify();
        }

        for (let scrollTop = 2000; scrollTop > 1500; scrollTop -= 100) {
          listView.viewport.scrollTo({ y: scrollTop });
          await sleep(redrawInterval);
          verify();
        }
      })
    ));

    it('should keep the DOM unchanged after scrolling to item', doAsync(
      async () => checkDOMUnchanged(async verify => {
        for (let index of [0, 1, 11, 111]) {
          await scrollToItem(index, 'top');
          verify();
          checkItemLocation(index, 'top');
        }

        for (let index of [111, 110, 100]) {
          await scrollToItem(index, 'bottom');
          verify();
          checkItemLocation(index, 'bottom');
        }

        for (let index of [111, 110, 100]) {
          await scrollToItem(index, 'middle');
          verify();
          checkItemLocation(index, 'middle');
        }

        await scrollToItem(0, 'bottom');
        verify();
        checkScrolledToTop();

        await scrollToItem(listView.length - 1, 'top');
        verify();
        checkScrolledToBottom();

        await scrollToItem(0, 'middle');
        verify();
        checkScrolledToTop();

        await scrollToItem(listView.length - 1, 'middle');
        verify();
        checkScrolledToBottom();
      })
    ));
  });
});