From 0d10faccd03b53ec78222eb82b79f1882c96a64a Mon Sep 17 00:00:00 2001 From: Rene Haas Date: Wed, 24 Aug 2022 01:18:09 +0200 Subject: [PATCH] improve tests and code --- .../src/overlayscrollbars.ts | 40 +- .../overlayscrollbars/src/setups/setups.ts | 11 +- .../setups/structureSetup/structureSetup.ts | 21 +- .../jest-jsdom/overlayscrollbars.test.ts | 408 ++++++++++++++++++ .../jest-jsdom/support/eventListeners.test.ts | 30 ++ 5 files changed, 471 insertions(+), 39 deletions(-) create mode 100644 packages/overlayscrollbars/tests/jest-jsdom/overlayscrollbars.test.ts diff --git a/packages/overlayscrollbars/src/overlayscrollbars.ts b/packages/overlayscrollbars/src/overlayscrollbars.ts index 21d5717..0da5243 100644 --- a/packages/overlayscrollbars/src/overlayscrollbars.ts +++ b/packages/overlayscrollbars/src/overlayscrollbars.ts @@ -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; - update(force?: boolean): OverlayScrollbars; - - destroy(): void; - - state(): State; - - elements(): Elements; - on(name: N, listener: EventListener): () => void; on(name: N, listener: EventListener[]): () => void; off(name: N, listener: EventListener): void; off(name: N, listener: EventListener[]): 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, 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, force?: boolean) => { + const update = (changedOptions: DeepPartial, 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); diff --git a/packages/overlayscrollbars/src/setups/setups.ts b/packages/overlayscrollbars/src/setups/setups.ts index 1ef031d..4754780 100644 --- a/packages/overlayscrollbars/src/setups/setups.ts +++ b/packages/overlayscrollbars/src/setups/setups.ts @@ -4,11 +4,11 @@ import type { DeepPartial } from 'typings'; export type SetupElements> = [elements: T, destroy: () => void]; -export type SetupUpdate = ( +export type SetupUpdate = ( changedOptions: DeepPartial, force: boolean, - ...args: T -) => void; + ...args: Args +) => R; export type SetupUpdateCheckOption = (path: string) => [value: T, changed: boolean]; @@ -26,8 +26,9 @@ export type SetupState> = [ export type Setup< DynamicState, StaticState extends Record = Record, - A extends any[] = [] -> = [update: SetupUpdate, state: (() => DynamicState) & StaticState, destroy: () => void]; + Args extends any[] = [], + R = void +> = [update: SetupUpdate, state: (() => DynamicState) & StaticState, destroy: () => void]; const getPropByPath = (obj: any, path: string): T => obj diff --git a/packages/overlayscrollbars/src/setups/structureSetup/structureSetup.ts b/packages/overlayscrollbars/src/setups/structureSetup/structureSetup.ts index 297e006..ecf27fe 100644 --- a/packages/overlayscrollbars/src/setups/structureSetup/structureSetup.ts +++ b/packages/overlayscrollbars/src/setups/structureSetup/structureSetup.ts @@ -68,34 +68,33 @@ const initialStructureSetupUpdateState: StructureSetupState = { export const createStructureSetup = ( target: InitializationTarget, options: ReadonlyOptions -): Setup => { +): Setup => { const checkOptionsFallback = createOptionCheck(options, {}); const state = createState(initialStructureSetupUpdateState); const [addEvent, removeEvent, triggerEvent] = createEventListenerHub(); 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 diff --git a/packages/overlayscrollbars/tests/jest-jsdom/overlayscrollbars.test.ts b/packages/overlayscrollbars/tests/jest-jsdom/overlayscrollbars.test.ts new file mode 100644 index 0000000..ba1d5c5 --- /dev/null +++ b/packages/overlayscrollbars/tests/jest-jsdom/overlayscrollbars.test.ts @@ -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 = { + 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 = { + 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); + }); + }); +}); diff --git a/packages/overlayscrollbars/tests/jest-jsdom/support/eventListeners.test.ts b/packages/overlayscrollbars/tests/jest-jsdom/support/eventListeners.test.ts index 2b45502..776208b 100644 --- a/packages/overlayscrollbars/tests/jest-jsdom/support/eventListeners.test.ts +++ b/packages/overlayscrollbars/tests/jest-jsdom/support/eventListeners.test.ts @@ -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({ 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(); @@ -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({ @@ -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