improve tests and code

This commit is contained in:
Rene Haas
2022-08-24 01:18:09 +02:00
parent 73419d25e0
commit 0d10faccd0
5 changed files with 471 additions and 39 deletions
@@ -37,7 +37,7 @@ export interface OverlayScrollbarsStatic {
): OverlayScrollbars;
plugin(plugin: Plugin | Plugin[]): void;
valid(osInstance: any): boolean;
valid(osInstance: any): osInstance is OverlayScrollbars;
env(): Environment;
}
@@ -94,19 +94,19 @@ export interface OverlayScrollbars {
options(): Options;
options(newOptions: DeepPartial<Options>): Options;
update(force?: boolean): OverlayScrollbars;
destroy(): void;
state(): State;
elements(): Elements;
on<N extends keyof EventListenerMap>(name: N, listener: EventListener<N>): () => void;
on<N extends keyof EventListenerMap>(name: N, listener: EventListener<N>[]): () => void;
off<N extends keyof EventListenerMap>(name: N, listener: EventListener<N>): void;
off<N extends keyof EventListenerMap>(name: N, listener: EventListener<N>[]): void;
update(force?: boolean): boolean;
state(): State;
elements(): Elements;
destroy(): void;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
@@ -115,11 +115,7 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = (
options?: DeepPartial<Options>,
eventListeners?: InitialEventListeners
) => {
const {
_getDefaultOptions,
_getDefaultInitialization,
_addListener: addEnvListener,
} = getEnvironment();
const { _getDefaultOptions, _getDefaultInitialization, _addListener } = getEnvironment();
const plugins = getPlugins();
const targetIsElement = isHTMLElement(target);
const instanceTarget = targetIsElement ? target : target.target;
@@ -149,10 +145,9 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = (
currentOptions,
structureState
);
const update = (changedOptions: DeepPartial<Options>, force?: boolean) => {
const update = (changedOptions: DeepPartial<Options>, force?: boolean): boolean =>
updateStructure(changedOptions, !!force);
};
const removeEnvListener = addEnvListener(update.bind(0, {}, true));
const removeEnvListener = _addListener(update.bind(0, {}, true));
const destroy = (canceled?: boolean) => {
removeInstance(instanceTarget);
removeEnvListener();
@@ -257,10 +252,7 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = (
}
);
},
update(force?: boolean) {
update({}, force);
return instance;
},
update: (force?: boolean) => update({}, force),
destroy: destroy.bind(0),
};
@@ -323,13 +315,15 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = (
]);
});
return instance.update(true);
instance.update(true);
return instance;
}
return potentialInstance!;
};
OverlayScrollbars.plugin = addPlugin;
OverlayScrollbars.valid = (osInstance: any) => {
OverlayScrollbars.valid = (osInstance: any): osInstance is OverlayScrollbars => {
const hasElmsFn = osInstance && (osInstance as OverlayScrollbars).elements;
const elements = isFunction(hasElmsFn) && hasElmsFn();
return isPlainObject(elements) && !!getInstance(elements.target);
@@ -4,11 +4,11 @@ import type { DeepPartial } from 'typings';
export type SetupElements<T extends Record<string, any>> = [elements: T, destroy: () => void];
export type SetupUpdate<T extends any[]> = (
export type SetupUpdate<Args extends any[], R> = (
changedOptions: DeepPartial<Options>,
force: boolean,
...args: T
) => void;
...args: Args
) => R;
export type SetupUpdateCheckOption = <T>(path: string) => [value: T, changed: boolean];
@@ -26,8 +26,9 @@ export type SetupState<T extends Record<string, any>> = [
export type Setup<
DynamicState,
StaticState extends Record<string, any> = Record<string, any>,
A extends any[] = []
> = [update: SetupUpdate<A>, state: (() => DynamicState) & StaticState, destroy: () => void];
Args extends any[] = [],
R = void
> = [update: SetupUpdate<Args, R>, state: (() => DynamicState) & StaticState, destroy: () => void];
const getPropByPath = <T>(obj: any, path: string): T =>
obj
@@ -68,34 +68,33 @@ const initialStructureSetupUpdateState: StructureSetupState = {
export const createStructureSetup = (
target: InitializationTarget,
options: ReadonlyOptions
): Setup<StructureSetupState, StructureSetupStaticState> => {
): Setup<StructureSetupState, StructureSetupStaticState, [], boolean> => {
const checkOptionsFallback = createOptionCheck(options, {});
const state = createState(initialStructureSetupUpdateState);
const [addEvent, removeEvent, triggerEvent] = createEventListenerHub<StructureSetupEventMap>();
const [getState] = state;
const [elements, appendStructureElements, destroyElements] = createStructureSetupElements(target);
const updateStructure = createStructureSetupUpdate(elements, state);
const triggerUpdateEvent: (...args: StructureSetupEventMap['u']) => void = (
const triggerUpdateEvent: (...args: StructureSetupEventMap['u']) => boolean = (
updateHints,
changedOptions,
force
) => {
const truthyUpdateHints = keys(updateHints).some((key) => updateHints[key]);
if (truthyUpdateHints || !isEmptyObject(changedOptions) || force) {
const changed = truthyUpdateHints || !isEmptyObject(changedOptions) || force;
if (changed) {
triggerEvent('u', [updateHints, changedOptions, force]);
}
return changed;
};
const [destroyObservers, appendObserverElements, updateObservers, updateObserversOptions] =
createStructureSetupObservers(elements, state, (updateHints) => {
triggerUpdateEvent(updateStructure(checkOptionsFallback, updateHints), {}, false);
});
createStructureSetupObservers(elements, state, (updateHints) =>
triggerUpdateEvent(updateStructure(checkOptionsFallback, updateHints), {}, false)
);
const structureSetupState = getState.bind(0) as (() => StructureSetupState) &
StructureSetupStaticState;
structureSetupState._addOnUpdatedListener = (listener) => {
addEvent('u', listener);
};
structureSetupState._addOnUpdatedListener = (listener) => addEvent('u', listener);
structureSetupState._appendElements = () => {
const { _target, _viewport } = elements;
const initialScrollLeft = scrollLeft(_target);
@@ -113,7 +112,7 @@ export const createStructureSetup = (
(changedOptions, force?) => {
const checkOption = createOptionCheck(options, changedOptions, force);
updateObserversOptions(checkOption);
triggerUpdateEvent(
return triggerUpdateEvent(
updateStructure(checkOption, updateObservers(), force),
changedOptions,
!!force
@@ -0,0 +1,408 @@
import { DeepPartial } from 'typings';
import { defaultOptions, Options } from 'options';
import { assignDeep } from 'support';
import { OverlayScrollbars as originalOverlayScrollbars } from '../../src/overlayscrollbars';
const bodyElm = document.body;
const div = document.createElement('div');
bodyElm.append(div);
let OverlayScrollbars = originalOverlayScrollbars;
describe('overlayscrollbars', () => {
beforeEach(async () => {
jest.resetModules();
({ OverlayScrollbars } = await import('../../src/overlayscrollbars'));
});
afterEach(() => {
const instance = OverlayScrollbars(div);
if (OverlayScrollbars.valid(instance)) {
instance.destroy();
}
});
describe('instance', () => {
describe('initialization', () => {
describe('initialization completed', () => {
[div, { target: div }].forEach((init) => {
describe(`as ${init === div ? 'element' : 'object'}`, () => {
test('without options', () => {
const osInstance = OverlayScrollbars(init);
expect(OverlayScrollbars.valid(osInstance)).toBe(false);
expect(div.children.length).toBe(0);
});
test('with empty options', () => {
const osInstance = OverlayScrollbars(init, {});
expect(OverlayScrollbars.valid(osInstance)).toBe(true);
expect(div.children.length).not.toBe(0);
expect(osInstance.options()).not.toBe(defaultOptions);
expect(osInstance.options()).toEqual(defaultOptions);
});
test('with custom options', () => {
const customOptions: DeepPartial<Options> = {
paddingAbsolute: false,
showNativeOverlaidScrollbars: true,
overflow: {
x: 'hidden',
},
update: {
ignoreMutation: () => true,
elementEvents: null,
},
scrollbars: {
pointers: null,
autoHideDelay: 0,
},
};
const osInstance = OverlayScrollbars(init, customOptions);
expect(OverlayScrollbars.valid(osInstance)).toBe(true);
expect(div.children.length).not.toBe(0);
expect(osInstance.options()).toEqual(assignDeep({}, defaultOptions, customOptions));
});
test('with event listeners', () => {
const initialized = jest.fn();
const updated = jest.fn();
const destroyed = jest.fn();
const osInstance = OverlayScrollbars(
init,
{},
{
initialized,
updated,
destroyed,
}
);
expect(OverlayScrollbars.valid(osInstance)).toBe(true);
expect(div.children.length).not.toBe(0);
expect(initialized).toHaveBeenCalledTimes(1);
expect(initialized).toHaveBeenCalledWith(osInstance);
expect(updated).toHaveBeenCalledTimes(1);
expect(updated).toHaveBeenCalledWith(osInstance, expect.any(Object));
expect(destroyed).not.toHaveBeenCalled();
osInstance.destroy();
expect(destroyed).toHaveBeenCalledTimes(1);
expect(destroyed).toHaveBeenCalledWith(osInstance, false);
});
});
});
});
describe('initialization canceled', () => {
test('without options', () => {
const osInstance = OverlayScrollbars({
target: div,
cancel: { nativeScrollbarsOverlaid: true },
});
expect(OverlayScrollbars.valid(osInstance)).toBe(false);
expect(div.children.length).toBe(0);
});
test('with empty options', () => {
const osInstance = OverlayScrollbars(
{
target: div,
cancel: { nativeScrollbarsOverlaid: true },
},
{}
);
expect(OverlayScrollbars.valid(osInstance)).toBe(false);
expect(div.children.length).toBe(0);
});
test('with event listeners', () => {
const initialized = jest.fn();
const updated = jest.fn();
const destroyed = jest.fn();
const osInstance = OverlayScrollbars(
{
target: div,
cancel: { nativeScrollbarsOverlaid: true },
},
{},
{
initialized,
updated,
destroyed,
}
);
expect(OverlayScrollbars.valid(osInstance)).toBe(false);
expect(div.children.length).toBe(0);
expect(initialized).not.toHaveBeenCalled();
expect(updated).not.toHaveBeenCalled();
expect(destroyed).toHaveBeenCalledTimes(1);
expect(destroyed).toHaveBeenCalledWith(osInstance, true);
});
});
});
test('options', () => {
const customOptions: DeepPartial<Options> = {
paddingAbsolute: !defaultOptions.paddingAbsolute,
overflow: { x: 'hidden' },
};
const osInstance = OverlayScrollbars(div, {});
expect(osInstance.options()).not.toBe(defaultOptions);
expect(osInstance.options()).toEqual(defaultOptions);
OverlayScrollbars(div)!.destroy();
expect(OverlayScrollbars(div, customOptions).options()).toEqual(
assignDeep({}, defaultOptions, customOptions)
);
OverlayScrollbars(div)!.destroy();
const osInstance2 = OverlayScrollbars(div, {});
expect(osInstance2.options(customOptions)).toEqual(
assignDeep({}, defaultOptions, customOptions)
);
expect(osInstance2.options()).toEqual(assignDeep({}, defaultOptions, customOptions));
});
test('on', () => {
const onInitialized = jest.fn();
const onUpdated = jest.fn();
const onUpdated2 = jest.fn();
const onDestroyed = jest.fn();
const osInstance = OverlayScrollbars(div, {});
osInstance.on('initialized', onInitialized);
osInstance.on('updated', [onUpdated, onUpdated, onUpdated2]);
osInstance.on('destroyed', onDestroyed);
expect(onInitialized).not.toHaveBeenCalled();
expect(onUpdated).not.toHaveBeenCalled();
expect(onUpdated2).not.toHaveBeenCalled();
osInstance.update();
expect(onUpdated).not.toHaveBeenCalled();
expect(onUpdated2).not.toHaveBeenCalled();
osInstance.update(true);
expect(onUpdated).toHaveBeenCalledTimes(1);
expect(onUpdated2).toHaveBeenCalledTimes(1);
expect(onUpdated).toHaveBeenLastCalledWith(osInstance, expect.any(Object));
expect(onUpdated2).toHaveBeenLastCalledWith(osInstance, expect.any(Object));
expect(onDestroyed).not.toHaveBeenCalled();
OverlayScrollbars(div)!.destroy();
expect(onDestroyed).toHaveBeenCalledTimes(1);
expect(onDestroyed).toHaveBeenLastCalledWith(osInstance, false);
// after destruction no further events are triggered
osInstance.update(true);
expect(onUpdated).toHaveBeenCalledTimes(1);
expect(onUpdated2).toHaveBeenCalledTimes(1);
});
test('off', () => {
const onInitialized = jest.fn();
const onUpdated = jest.fn();
const onUpdated2 = jest.fn();
const onDestroyed = jest.fn();
expect(onInitialized).not.toHaveBeenCalled();
const osInstance = OverlayScrollbars(
div,
{},
{
initialized: onInitialized,
updated: [onUpdated, onUpdated, onUpdated2],
destroyed: onDestroyed,
}
);
expect(onInitialized).toHaveBeenCalledTimes(1);
expect(onInitialized).toHaveBeenLastCalledWith(osInstance);
expect(onUpdated).toHaveBeenCalledTimes(1);
expect(onUpdated2).toHaveBeenCalledTimes(1);
osInstance.update(true);
expect(onUpdated).toHaveBeenCalledTimes(2);
expect(onUpdated2).toHaveBeenCalledTimes(2);
osInstance.off('updated', onUpdated2);
osInstance.update(true);
expect(onUpdated).toHaveBeenCalledTimes(3);
expect(onUpdated2).toHaveBeenCalledTimes(2);
osInstance.on('updated', onUpdated2);
osInstance.update(true);
expect(onUpdated).toHaveBeenCalledTimes(4);
expect(onUpdated2).toHaveBeenCalledTimes(3);
osInstance.off('updated', [onUpdated, onUpdated2]);
osInstance.update(true);
expect(onUpdated).toHaveBeenCalledTimes(4);
expect(onUpdated2).toHaveBeenCalledTimes(3);
osInstance.off('destroyed', onDestroyed);
expect(onDestroyed).not.toHaveBeenCalled();
osInstance.destroy();
expect(onDestroyed).not.toHaveBeenCalled();
});
test('state', () => {
const osInstance = OverlayScrollbars(div, {});
const state = osInstance.state();
const stateObj = {
overflowEdge: { x: 0, y: 0 },
overflowAmount: { x: 0, y: 0 },
overflowStyle: { x: '', y: '' },
hasOverflow: { x: false, y: false },
padding: { t: 0, r: 0, b: 0, l: 0 },
paddingAbsolute: false,
directionRTL: false,
destroyed: false,
};
expect(state).not.toBe(osInstance.state());
expect(state).toEqual(osInstance.state());
expect(state).toEqual(stateObj);
osInstance.options({ paddingAbsolute: true });
expect(osInstance.state()).toEqual(
expect.objectContaining({
paddingAbsolute: true,
})
);
osInstance.destroy();
expect(osInstance.state()).toEqual(
expect.objectContaining({
destroyed: true,
})
);
});
test('elements', () => {
const osInstance = OverlayScrollbars(div, {});
const elements = osInstance.elements();
const elementsObj = {
target: div,
host: div,
padding: expect.any(HTMLElement),
viewport: expect.any(HTMLElement),
content: expect.any(HTMLElement),
scrollOffsetElement: expect.any(HTMLElement),
scrollEventElement: expect.any(HTMLElement),
scrollbarHorizontal: {
scrollbar: expect.any(HTMLElement),
track: expect.any(HTMLElement),
handle: expect.any(HTMLElement),
clone: expect.any(Function),
},
scrollbarVertical: {
scrollbar: expect.any(HTMLElement),
track: expect.any(HTMLElement),
handle: expect.any(HTMLElement),
clone: expect.any(Function),
},
};
expect(elements).not.toBe(osInstance.elements());
// clone function identity is always different
expect(
assignDeep({}, elements, {
scrollbarHorizontal: {
clone: null,
},
scrollbarVertical: {
clone: null,
},
})
).toEqual(
assignDeep({}, osInstance.elements(), {
scrollbarHorizontal: {
clone: null,
},
scrollbarVertical: {
clone: null,
},
})
);
expect(elements).toEqual(elementsObj);
osInstance.destroy();
expect(elements).toEqual(elementsObj);
});
test('update', () => {
const osInstance = OverlayScrollbars(div, {});
expect(osInstance.update()).toBe(false);
expect(osInstance.update(false)).toBe(false);
expect(osInstance.update(true)).toBe(true);
// host mutation
div.style.cursor = 'pointer';
expect(osInstance.update()).toBe(true);
});
});
describe('static', () => {
test('plugin', () => {
expect(OverlayScrollbars.plugin).toEqual(expect.any(Function));
});
test('env', () => {
const env = OverlayScrollbars.env();
const envObj = {
scrollbarsSize: { x: 0, y: 0 },
scrollbarsOverlaid: { x: true, y: true },
scrollbarsHiding: false,
rtlScrollBehavior: { i: true, n: false },
flexboxGlue: true,
cssCustomProperties: false,
staticDefaultInitialization: expect.any(Object),
staticDefaultOptions: expect.any(Object),
getDefaultInitialization: expect.any(Function),
setDefaultInitialization: expect.any(Function),
getDefaultOptions: expect.any(Function),
setDefaultOptions: expect.any(Function),
};
expect(env).not.toBe(OverlayScrollbars.env());
expect(env).toEqual(OverlayScrollbars.env());
expect(env).toEqual(envObj);
});
test('valid', () => {
expect(OverlayScrollbars.valid(true)).toBe(false);
expect(OverlayScrollbars.valid(false)).toBe(false);
expect(OverlayScrollbars.valid('')).toBe(false);
expect(OverlayScrollbars.valid(123)).toBe(false);
expect(OverlayScrollbars.valid({})).toBe(false);
expect(OverlayScrollbars.valid(div)).toBe(false);
expect(OverlayScrollbars.valid(setTimeout)).toBe(false);
expect(OverlayScrollbars.valid(OverlayScrollbars(div))).toBe(false);
const osInstance = OverlayScrollbars(div, {});
expect(OverlayScrollbars.valid(osInstance)).toBe(true);
osInstance.destroy();
expect(OverlayScrollbars.valid(osInstance)).toBe(false);
});
});
});
@@ -2,6 +2,7 @@ import { createEventListenerHub } from 'support/eventListeners';
type EventMap = {
onBoolean: [a: boolean, b: string];
onString: [a: string];
onUndefined: [];
};
@@ -10,19 +11,27 @@ describe('eventListeners', () => {
test('initialization', () => {
const onBooleanA = jest.fn((a: boolean, b: string) => a + b);
const onBooleanB = jest.fn();
const onString = jest.fn();
const onUndefined = jest.fn();
const [, , triggerEvent] = createEventListenerHub<EventMap>({
onBoolean: [onBooleanA, onBooleanB],
onString: [onString, onString], // multiple equal listeners are treated as one
onUndefined,
});
triggerEvent('onBoolean', [true, 'hi']);
triggerEvent('onString', ['hi']);
triggerEvent('onUndefined');
expect(onBooleanA).toHaveBeenCalledTimes(1);
expect(onBooleanA).toHaveBeenLastCalledWith(true, 'hi');
expect(onBooleanB).toHaveBeenCalledTimes(1);
expect(onBooleanB).toHaveBeenLastCalledWith(true, 'hi');
// even though onString was registered twice, its only called once
expect(onString).toHaveBeenCalledTimes(1);
expect(onString).toHaveBeenLastCalledWith('hi');
expect(onUndefined).toHaveBeenCalledTimes(1);
expect(onUndefined).toHaveBeenLastCalledWith();
});
@@ -30,6 +39,7 @@ describe('eventListeners', () => {
test('addEvent', () => {
const onBooleanA = jest.fn((a: boolean, b: string) => a + b);
const onBooleanB = jest.fn();
const onString = jest.fn();
const onUndefinedA = jest.fn();
const onUndefinedB = jest.fn();
const [addEvent, , triggerEvent] = createEventListenerHub<EventMap>();
@@ -68,6 +78,14 @@ describe('eventListeners', () => {
expect(onUndefinedB).toHaveBeenCalledTimes(2);
expect(onUndefinedB).toHaveBeenLastCalledWith();
// multiple equal listeners are treated as one
addEvent('onString', [onString, onString]);
triggerEvent('onString', ['hi']);
// even though onString was registered twice, its only called once
expect(onString).toHaveBeenCalledTimes(1);
expect(onString).toHaveBeenLastCalledWith('hi');
const something = jest.fn();
// @ts-ignore
addEvent('something', something);
@@ -79,6 +97,7 @@ describe('eventListeners', () => {
test('removeEvent', () => {
const onBooleanA = jest.fn();
const onBooleanB = jest.fn();
const onString = jest.fn();
const onUndefinedA = jest.fn();
const onUndefinedB = jest.fn();
const [addEvent, removeEvent, triggerEvent] = createEventListenerHub<EventMap>({
@@ -149,6 +168,17 @@ describe('eventListeners', () => {
expect(onUndefinedA).toHaveBeenCalledTimes(4);
expect(onUndefinedB).toHaveBeenCalledTimes(4);
addEvent('onString', [onString, onString]);
triggerEvent('onString', ['hi']);
expect(onString).toHaveBeenCalledTimes(1);
// even though onString was registered twice, its treated as a single listener because multiple equal listeners are treated as one
removeEvent('onString', onString);
triggerEvent('onString', ['hi']);
expect(onString).toHaveBeenCalledTimes(1);
// @ts-ignore
const something = jest.fn();
// @ts-ignore