test(virtual-scroll): adds unit tests (part 1)

This commit is contained in:
Manu Mtz.-Almeida
2018-02-03 07:13:48 +01:00
parent 40f9cd5ece
commit 9016a6b898
5 changed files with 559 additions and 138 deletions

View File

@@ -40,16 +40,24 @@
function renderItem(el, item) {
if (!el) {
el = document.createElement('ion-item');
const text = document.createTextNode(item);
el['$content'] = text;
el.appendChild(text);
} else {
el['$content'].nodeValue = item;
}
el.textContent = item;
return el;
}
function renderHeader(el, item) {
if (!el) {
el = document.createElement('ion-item-divider');
const text = document.createTextNode(item);
el['$content'] = text;
el.appendChild(text);
} else {
el['$content'].nodeValue = item;
}
el.textContent = item;
return el;
}

View File

@@ -67,14 +67,6 @@
};
const lorem = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
const images = [
"http://images.all-free-download.com/images/graphiclarge/travel_icons_6813629.jpg",
"https://images.unsplash.com/photo-1500531279542-fc8490c8ea4d?auto=format&fit=crop&w=1502&q=60&ixid=dW5zcGxhc2guY29tOzs7Ozs%3D",
"https://images.unsplash.com/photo-1483354483454-4cd359948304?dpr=1&auto=format&fit=crop&w=1000&q=80&cs=tinysrgb&ixid=dW5zcGxhc2guY29tOzs7Ozs%3D",
];
function getImgSrc() {
let src = images[rotateImg];
rotateImg++;
@@ -94,7 +86,6 @@
content: lorem.substring(0, (Math.random() * (lorem.length - 100)) + 100)
});
rotateImg++;
if (rotateImg === images.length) rotateImg = 0;
}
virtual.items = items;
};

View File

@@ -0,0 +1,393 @@
import { CellType, HeaderFn, ItemHeightFn, VirtualNode, calcCells, calcHeightIndex, getRange, getViewport, resizeBuffer, updateVDom, ItemRenderFn, Range } from '../virtual-scroll-utils';
describe('getViewport', () => {
it('should return viewport without margin', () => {
expect(getViewport(0, 100, 0)).toEqual({
top: 0,
bottom: 100,
});
});
it('should return viewport with margin', () => {
expect(getViewport(0, 100, 150)).toEqual({
top: 0,
bottom: 250,
});
});
it('should return viewport with margin and scrollTop', () => {
expect(getViewport(150, 100, 150)).toEqual({
top: 0,
bottom: 400,
});
});
it('should return viewport with margin and scrollTop 2', () => {
expect(getViewport(100, 100, 10)).toEqual({
top: 90,
bottom: 210,
});
});
});
describe('getRange', () => {
it('should return initial bounds without buffer', () => {
const heightBuffer = mockHeightBuffer(20, () => 10);
const bounds = getRange(heightBuffer, {top: 0, bottom: 100}, 0);
expect(bounds).toEqual({
offset: 0,
length: 10,
});
});
it('should return initial bounds with buffer', () => {
const heightBuffer = mockHeightBuffer(20, () => 10);
const bounds = getRange(heightBuffer, {top: 0, bottom: 100}, 4);
expect(bounds).toEqual({
offset: 0,
length: 14,
});
});
it('should return initial bounds truncked', () => {
const heightBuffer = mockHeightBuffer(5, () => 10);
const bounds = getRange(heightBuffer, {top: 0, bottom: 100}, 4);
expect(bounds).toEqual({
offset: 0,
length: 4,
});
});
it('should return just first component', () => {
const heightBuffer = mockHeightBuffer(5, () => 100);
expect(getRange(heightBuffer, {top: 0, bottom: 100}, 0)).toEqual({
offset: 0,
length: 1,
});
expect(getRange(heightBuffer, {top: 50, bottom: 100}, 0)).toEqual({
offset: 0,
length: 1,
});
expect(getRange(heightBuffer, {top: 100, bottom: 200}, 0)).toEqual({
offset: 1,
length: 1,
});
});
it('should return just two components', () => {
const heightBuffer = mockHeightBuffer(5, () => 100);
expect(getRange(heightBuffer, {top: 1, bottom: 101}, 0)).toEqual({
offset: 0,
length: 2,
});
expect(getRange(heightBuffer, {top: 99, bottom: 200}, 0)).toEqual({
offset: 0,
length: 2,
});
expect(getRange(heightBuffer, {top: 100, bottom: 201}, 0)).toEqual({
offset: 1,
length: 2,
});
});
it('should return three components', () => {
const heightBuffer = mockHeightBuffer(5, () => 100);
expect(getRange(heightBuffer, {top: 99, bottom: 201}, 0)).toEqual({
offset: 0,
length: 3,
});
});
});
describe('resizeBuffer', () => {
it('should allocate a buffer', () => {
const buf = resizeBuffer(null, 10);
expect(buf.length).toEqual(10);
});
it('should not allocate a buffer', () => {
const buf = new Uint32Array(10);
const buf2 = resizeBuffer(buf, 10);
expect(buf).toBe(buf2);
expect(buf.length).toEqual(10);
});
it('should grow a buffer', () => {
const buf = new Uint32Array(10);
buf[0] = 100;
buf[9] = 123;
const buf2 = resizeBuffer(buf, 12);
expect(buf2.length).toEqual(12);
expect(buf2[0]).toEqual(100);
expect(buf2[9]).toEqual(123);
});
it('should shrink a buffer', () => {
const buf = new Uint32Array(10);
buf[0] = 100;
buf[9] = 123;
const buf2 = resizeBuffer(buf, 5);
expect(buf2.length).toEqual(5);
expect(buf2[0]).toEqual(100);
});
});
describe('calcCells', () => {
it('should calculate cells without headers and itemHeight', () => {
const items = ['0', 2, 'hola', {data: 'hello'}];
const cells = calcCells(items, null, null, null, 10, 20, 30);
expect(cells).toEqual([
{
type: CellType.Item,
value: '0',
i: 0,
index: 0,
height: 30,
reads: 2,
visible: false,
},
{
type: CellType.Item,
value: 2,
i: 1,
index: 1,
height: 30,
reads: 2,
visible: false,
},
{
type: CellType.Item,
value: 'hola',
i: 2,
index: 2,
height: 30,
reads: 2,
visible: false,
},
{
type: CellType.Item,
value: {data: 'hello'},
i: 3,
index: 3,
height: 30,
reads: 2,
visible: false,
}
]);
});
it('should calculate cells with itemHeight', () => {
const items = [10, 9, 8];
let called = 0;
const itemHeight: ItemHeightFn = (item: any, index?: number) => {
expect(item).toEqual(items[index]);
called++;
return index * 20 + 20;
};
const cells = calcCells(items, itemHeight, null, null, 10, 20, 30);
expect(called).toEqual(3);
expect(cells).toEqual([
{
type: CellType.Item,
value: 10,
i: 0,
index: 0,
height: 20,
reads: 0,
visible: true,
},
{
type: CellType.Item,
value: 9,
i: 1,
index: 1,
height: 40,
reads: 0,
visible: true,
},
{
type: CellType.Item,
value: 8,
i: 2,
index: 2,
height: 60,
reads: 0,
visible: true,
}
]);
});
it('should calculate cells with header and footer', () => {
let headerCalled = 0;
let footerCalled = 0;
let called = 0;
const items = ['10', '9', '8'];
const headerFn: HeaderFn = (item, index, allItems) => {
expect(item).toEqual(items[index]);
expect(items).toBe(allItems);
headerCalled++;
return (index === 0) ? 'my header' : null;
};
const footerFn: HeaderFn = (item, index, allItems) => {
expect(item).toEqual(items[index]);
expect(items).toBe(allItems);
footerCalled++;
return (index === 2) ? 'my footer' : null;
};
const itemHeight: ItemHeightFn = (item: any, index?: number) => {
expect(item).toEqual(items[index]);
called++;
return index * 20 + 20;
};
const cells = calcCells(items, itemHeight, headerFn, footerFn, 10, 20, 30);
expect(cells).toHaveLength(5);
expect(called).toEqual(3);
expect(headerCalled).toEqual(3);
expect(footerCalled).toEqual(3);
expect(cells).toEqual([
{
type: CellType.Header,
value: 'my header',
i: 0,
index: 0,
height: 10,
reads: 2,
visible: false,
},
{
type: CellType.Item,
value: '10',
i: 1,
index: 0,
height: 20,
reads: 0,
visible: true,
},
{
type: CellType.Item,
value: '9',
i: 2,
index: 1,
height: 40,
reads: 0,
visible: true,
},
{
type: CellType.Item,
value: '8',
i: 3,
index: 2,
height: 60,
reads: 0,
visible: true,
},
{
type: CellType.Footer,
value: 'my footer',
i: 4,
index: 2,
height: 20,
reads: 2,
visible: false,
}
]);
});
});
describe('calcHeightIndex', () => {
it('should generate height index', () => {
const items = [1, 2, 3, 4, 5];
const headerFn: HeaderFn = (_, index) => {
return (index === 0) ? 'my header' : null;
};
const footerFn: HeaderFn = (_, index) => {
return (index === 2) ? 'my footer' : null;
};
const cells = calcCells(items, null, headerFn, footerFn, 10, 20, 50);
const buf = resizeBuffer(null, cells.length);
const totalHeight = calcHeightIndex(buf, cells, 0);
expect(buf.length).toEqual(7);
expect(buf[0]).toEqual(0);
expect(buf[1]).toEqual(10);
expect(buf[2]).toEqual(60);
expect(buf[3]).toEqual(110);
expect(buf[4]).toEqual(160);
expect(buf[5]).toEqual(180);
expect(buf[6]).toEqual(230);
expect(totalHeight).toEqual(280);
});
});
describe('updateVDom', () => {
it('should initialize empty VDOM', () => {
const vdom: VirtualNode[] = [];
const items = [1, 2, 3, 4, 5];
const {heightIndex, cells} = mockVirtualScroll(items, () => 20);
const range: Range = {offset: 1, length: 4};
updateVDom(vdom, heightIndex, cells, range);
expect(vdom).toEqual([
{
cell: cells[1],
change: 2,
d: false,
top: 20,
},
{
cell: cells[2],
change: 2,
d: false,
top: 40,
},
{
cell: cells[3],
change: 2,
d: false,
top: 60,
},
{
cell: cells[4],
change: 2,
d: false,
top: 80,
}
]);
});
});
function mockVirtualScroll(
items: any[],
itemHeight: ItemHeightFn = null,
headerFn: HeaderFn = null,
footerFn: HeaderFn = null
) {
const cells = calcCells(items, itemHeight, headerFn, footerFn, 10, 20, 30);
const heightIndex = resizeBuffer(null, cells.length);
calcHeightIndex(heightIndex, cells, 0);
return { items, heightIndex, cells };
}
function mockHeightBuffer(size: number, step: Function) {
const buf = new Uint32Array(size);
let acum = 0;
for (let i = 0; i < size; i++) {
buf[i] = acum;
acum += step(i);
}
return buf;
}

View File

@@ -1,3 +1,12 @@
export interface Viewport {
top: number;
bottom: number;
}
export interface Range {
offset: number;
length: number;
}
export const enum CellType {
Item,
@@ -5,6 +14,12 @@ export const enum CellType {
Footer
}
export const enum NodeChange {
NoChange,
Position,
Cell,
}
export interface Cell {
type: CellType;
value: any;
@@ -18,9 +33,11 @@ export interface Cell {
export interface VirtualNode {
cell: Cell;
top: number;
change: number;
_d: boolean;
change: NodeChange;
d: boolean;
}
const MIN_READS = 2;
export type NodeHeightFn = (node: VirtualNode, index: number) => number;
export type HeaderFn = (item: any, index: number, items: any[]) => string | null;
@@ -28,47 +45,50 @@ export type ItemHeightFn = (item: any, index?: number) => number;
export type ItemRenderFn = (el: HTMLElement|null, cell: Cell, domIndex?: number) => HTMLElement;
export type DomRenderFn = (dom: VirtualNode[], height: number) => void;
export function updateVDom(dom: VirtualNode[], heightIndex: Uint32Array, cells: Cell[], top: number, bottom: number) {
export function updateVDom(dom: VirtualNode[], heightIndex: Uint32Array, cells: Cell[], range: Range) {
// reset dom
for (const node of dom) {
node.top = -9999;
node.change = 0;
node._d = true;
// node.top = -9999;
node.change = NodeChange.NoChange;
node.d = true;
}
// try to match into exisiting dom
const toMutate = [];
const end = bottom + 1;
const end = range.offset + range.length;
for (let i = top; i < end; i++) {
for (let i = range.offset; i < end; i++) {
const cell = cells[i];
const node = dom.find((n) => n._d && n.cell === cell);
const node = dom.find((n) => n.d && n.cell === cell);
if (node) {
node._d = false;
node.change = 1;
node.top = heightIndex[i];
const top = heightIndex[i];
if (top !== node.top) {
node.top = top;
node.change = NodeChange.Position;
}
node.d = false;
} else {
toMutate.push(cell);
}
}
// needs to append
const pool = dom.filter((n) => n._d);
const pool = dom.filter((n) => n.d);
// console.log('toMutate', toMutate.length);
for (const cell of toMutate) {
const node = pool.find(n => n._d && n.cell.type === cell.type);
const node = pool.find(n => n.d && n.cell.type === cell.type);
const index = cell.index;
if (node) {
node._d = false;
node.change = 2;
node.d = false;
node.change = NodeChange.Cell;
node.cell = cell;
node.top = heightIndex[index];
} else {
dom.push({
_d: false,
change: 2,
d: false,
cell: cell,
change: NodeChange.Cell,
top: heightIndex[index],
});
}
@@ -81,7 +101,9 @@ export function doRender(el: HTMLElement, itemRender: ItemRenderFn, dom: Virtual
for (let i = 0; i < dom.length; i++) {
const node = dom[i];
const cell = node.cell;
if (node.change === 2) {
// the cell change, the content must be updated
if (node.change === NodeChange.Cell) {
if (i < children.length) {
child = children[i] as HTMLElement;
itemRender(child, cell, i);
@@ -90,11 +112,13 @@ export function doRender(el: HTMLElement, itemRender: ItemRenderFn, dom: Virtual
child.classList.add('virtual-item');
el.appendChild(child);
}
(child as any)['$ionCell'] = cell;
} else {
child = children[i] as HTMLElement;
}
(child as any)['$ionCell'] = cell;
if (node.change !== 0) {
// only update position when it changes
if (node.change !== NodeChange.NoChange) {
child.style.transform = `translate3d(0,${node.top}px,0)`;
}
if (cell.visible) {
@@ -109,29 +133,18 @@ export function doRender(el: HTMLElement, itemRender: ItemRenderFn, dom: Virtual
el.style.height = total + 'px';
}
export function doHeight(el: HTMLElement, index: number) {
const e = (el.children[index] as HTMLElement);
// const style = window.getComputedStyle(e);
return e.offsetHeight;
}
export function getTotalHeight(heightIndex: Uint32Array) {
return heightIndex[heightIndex.length - 1];
}
export interface Viewport {
top: number;
bottom: number;
}
export function getViewport(scrollTop: number, vierportHeight: number, margin: number): Viewport {
return {
top: scrollTop - margin,
top: Math.max(scrollTop - margin, 0),
bottom: scrollTop + vierportHeight + margin
};
}
export function getBounds(heightIndex: Uint32Array, viewport: Viewport, buffer: number) {
export function getRange(heightIndex: Uint32Array, viewport: Viewport, buffer: number): Range {
const topPos = viewport.top;
const bottomPos = viewport.bottom;
@@ -142,42 +155,96 @@ export function getBounds(heightIndex: Uint32Array, viewport: Viewport, buffer:
break;
}
}
const top = Math.max(i - buffer, 0);
const offset = Math.max(i - buffer - 1, 0);
// find bottom index
for (; i < heightIndex.length; i++) {
if (heightIndex[i] > bottomPos) {
if (heightIndex[i] >= bottomPos) {
break;
}
}
const bottom = Math.min(i + buffer, heightIndex.length - 1);
return { top, bottom };
const end = Math.min(i + buffer, heightIndex.length - 1);
const length = end - offset;
return { offset, length };
}
export function getShouldUpdate(dirtyIndex: number, currentTop: number, currentBottom: number, top: number, bottom: number) {
export function getShouldUpdate(dirtyIndex: number, currentRange: Range, range: Range) {
const end = range.offset + range.length;
return (
dirtyIndex < bottom ||
currentTop !== top ||
currentBottom !== bottom
dirtyIndex < end ||
currentRange.offset !== range.offset ||
currentRange.length !== range.length
);
}
export function calcHeightIndex(buf: Uint32Array, cells: Cell[], index: number, bottom: number) {
if (!cells) {
return buf;
}
buf = resizeBuffer(buf, cells.length);
export function calcCells(
items: any[],
itemHeight: ItemHeightFn,
headerFn: HeaderFn,
footerFn: HeaderFn,
approxHeaderHeight: number,
approxFooterHeight: number,
approxItemHeight: number
): Cell[] {
const cells = [];
let j = 0;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (headerFn) {
const value = headerFn(item, i, items);
if (value != null) {
cells.push({
i: j++,
type: CellType.Header,
value: value,
index: i,
height: approxHeaderHeight,
reads: MIN_READS,
visible: false,
});
}
}
cells.push({
i: j++,
type: CellType.Item,
value: item,
index: i,
height: itemHeight ? itemHeight(item, i) : approxItemHeight,
reads: itemHeight ? 0 : MIN_READS,
visible: !!itemHeight,
});
if (footerFn) {
const value = footerFn(item, i, items);
if (value != null) {
cells.push({
i: j++,
type: CellType.Footer,
value: value,
index: i,
height: approxFooterHeight,
reads: 2,
visible: false,
});
}
}
}
return cells;
}
export function calcHeightIndex(buf: Uint32Array, cells: Cell[], index: number): number {
let acum = buf[index];
for (; index < buf.length; index++) {
buf[index] = acum;
acum += cells[index].height;
// if (acum > bottom) {
// break;
// }
}
return buf;
return acum;
}
@@ -187,7 +254,19 @@ export function resizeBuffer(buf: Uint32Array, len: number) {
}
if (buf.length === len) {
return buf;
} else if (len > buf.length) {
const newBuf = new Uint32Array(len);
newBuf.set(buf);
return newBuf;
} else {
return buf.subarray(0, len);
}
return buf;
}
export function positionForIndex(index: number, cells: Cell[], heightIndex: Uint32Array): number {
const cell = cells.find(cell => cell.type === CellType.Item && cell.index === index);
if (cell) {
return heightIndex[cell.i];
}
return -1;
}

View File

@@ -1,11 +1,10 @@
import { Component, Element, EventListenerEnable, Listen, Method, Prop, Watch } from '@stencil/core';
import { DomController } from '../../index';
import { Cell, CellType, DomRenderFn, HeaderFn, ItemHeightFn, ItemRenderFn, NodeHeightFn,
Viewport, VirtualNode, calcHeightIndex, doRender, getBounds, getShouldUpdate, getViewport, updateVDom } from './virtual-scroll-utils';
import { Cell, DomRenderFn, HeaderFn, ItemHeightFn, ItemRenderFn, NodeHeightFn, Range,
Viewport, VirtualNode, calcCells, calcHeightIndex, doRender, getRange,
getShouldUpdate, getViewport, positionForIndex, resizeBuffer, updateVDom } from './virtual-scroll-utils';
const MIN_READS = 2;
@Component({
tag: 'ion-virtual-scroll',
styleUrl: 'virtual-scroll.scss'
@@ -13,8 +12,7 @@ const MIN_READS = 2;
export class VirtualScroll {
private scrollEl: HTMLElement;
private topIndex = -100;
private bottomIndex = -100;
private range: Range;
private timerUpdate: any;
private heightIndex: Uint32Array;
private viewportHeight: number;
@@ -141,11 +139,7 @@ export class VirtualScroll {
@Method()
positionForItem(index: number): number {
const cell = this.cells.find(cell => cell.type === CellType.Item && cell.index === index);
if (cell) {
return this.heightIndex[cell.i];
}
return -1;
return positionForIndex(index, this.cells, this.heightIndex);
}
private updateVirtualScroll() {
@@ -172,23 +166,21 @@ export class VirtualScroll {
const heightIndex = this.getHeightIndex(viewport);
// get array bounds of visible cells base in the viewport
const {top, bottom} = getBounds(heightIndex, viewport, 2);
const range = getRange(heightIndex, viewport, 2);
// fast path, do nothing
const shouldUpdate = getShouldUpdate(dirtyIndex, this.topIndex, this.bottomIndex, top, bottom);
const shouldUpdate = getShouldUpdate(dirtyIndex, this.range, range);
if (!shouldUpdate) {
return;
}
this.topIndex = top;
this.bottomIndex = bottom;
this.range = range;
// in place mutation of the virtual DOM
updateVDom(
this.virtualDom,
heightIndex,
this.cells,
top,
bottom);
range);
this.fireDomUpdate();
});
@@ -202,7 +194,7 @@ export class VirtualScroll {
}
}
updateCellHeight(cell: Cell, node: HTMLElement) {
private updateCellHeight(cell: Cell, node: HTMLElement) {
(node as any).componentOnReady(() => {
// let's give some additional time to read the height size
setTimeout(() => this.dom.read(() => {
@@ -215,7 +207,7 @@ export class VirtualScroll {
});
}
setCellHeight(cell: Cell, height: number) {
private setCellHeight(cell: Cell, height: number) {
const index = cell.i;
// the cell might changed since the height update was scheduled
if (cell !== this.cells[index]) {
@@ -247,74 +239,32 @@ export class VirtualScroll {
}
}
private calcCells() {
if (!this.items) {
return;
}
const items = this.items;
const cells = this.cells;
const headerFn = this.headerFn;
const footerFn = this.footerFn;
cells.length = 0;
this.cells = calcCells(
this.items,
this.itemHeight,
this.headerFn,
this.footerFn,
this.approxHeaderHeight,
this.approxFooterHeight,
this.approxItemHeight
);
this.indexDirty = 0;
let j = 0;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (headerFn) {
const value = headerFn(item, i, this.items);
if (value != null) {
cells.push({
i: j++,
type: CellType.Header,
value: value,
index: i,
height: this.approxHeaderHeight,
reads: MIN_READS,
visible: false,
});
}
}
cells.push({
i: j++,
type: CellType.Item,
value: item,
index: i,
height: this.itemHeight ? this.itemHeight(item, i) : this.approxItemHeight,
reads: this.itemHeight ? 0 : MIN_READS,
visible: !!this.itemHeight,
});
if (footerFn) {
const value = footerFn(item, i, this.items);
if (value != null) {
cells.push({
i: j++,
type: CellType.Footer,
value: value,
index: i,
height: this.approxFooterHeight,
reads: 2,
visible: false,
});
}
}
}
}
private getHeightIndex(viewport: Viewport): Uint32Array {
private getHeightIndex(_: Viewport): Uint32Array {
if (this.indexDirty !== Infinity) {
this.calcHeightIndex(this.indexDirty, viewport.bottom);
this.calcHeightIndex(this.indexDirty);
}
return this.heightIndex;
}
private calcHeightIndex(index = 0, bottom = Infinity) {
this.heightIndex = calcHeightIndex(this.heightIndex, this.cells, index, bottom);
this.totalHeight = this.heightIndex[this.heightIndex.length - 1];
private calcHeightIndex(index = 0) {
this.heightIndex = resizeBuffer(this.heightIndex, this.cells.length);
this.totalHeight = calcHeightIndex(this.heightIndex, this.cells, index);
this.indexDirty = Infinity;
}