import { LitElement } from 'lit'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { GalleryColumnCountRoundMethod, GalleryCoreController, } from '../../../src/components-lib/gallery/gallery-core-controller'; import { GalleryExtendEvent } from '../../../src/components/gallery/types'; import { scrollIntoView } from '../../../src/utils/scroll'; import { sleep } from '../../../src/utils/sleep'; import { callIntersectionHandler, callResizeHandler, createLitElement, createSlot, createSlotHost, createTouch, createTouchEvent, flushPromises, IntersectionObserverMock, ResizeObserverMock, } from '../../test-utils'; vi.mock('lodash-es', async () => { return { ...(await vi.importActual('lodash-es')), throttle: vi.fn((fn) => fn), }; }); vi.mock('../../../src/utils/sleep'); vi.mock('../../../src/utils/scroll'); // @vitest-environment jsdom describe('GalleryCoreController', () => { const createController = (options?: { host?: LitElement; getSlot?: () => HTMLSlotElement | null; getSentinelBottom?: () => HTMLElement | null; showLoaderTopCallback?: (show: boolean) => void; showSentinelBottomCallback?: (show: boolean) => void; }) => { return new GalleryCoreController( options?.host ?? createLitElement(), options?.getSlot ?? (() => null), options?.getSentinelBottom ?? (() => null), options?.showLoaderTopCallback ?? (() => null), options?.showSentinelBottomCallback ?? (() => null), ); }; beforeAll(() => { vi.stubGlobal('ResizeObserver', ResizeObserverMock); vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); }); beforeEach(() => { vi.clearAllMocks(); }); afterAll(() => { vi.unstubAllGlobals(); }); it('should add controller', () => { const host = createLitElement(); const controller = createController({ host }); expect(controller).toBeInstanceOf(GalleryCoreController); expect(host.addController).toBeCalledWith(controller); }); it('should remove controller', () => { const host = createLitElement(); const controller = createController({ host }); expect(controller).toBeInstanceOf(GalleryCoreController); controller.removeController(); expect(host.removeController).toBeCalledWith(controller); }); it('should remove controller', () => { const host = createLitElement(); const controller = createController({ host }); expect(controller).toBeInstanceOf(GalleryCoreController); controller.removeController(); expect(host.removeController).toBeCalledWith(controller); }); describe('should observe sentintel when host updated', () => { it('should disconnect and observe sentinel when valid sentinel exists', () => { const sentinel = document.createElement('div'); const getSentinelBottom = vi.fn(() => sentinel); const host = createLitElement(); const controller = createController({ host, getSentinelBottom, }); controller.hostUpdated(); expect( vi.mocked(IntersectionObserver).mock.results[0].value.disconnect, ).toBeCalled(); expect( vi.mocked(IntersectionObserver).mock.results[0].value.observe, ).toBeCalledWith(sentinel); }); it('should disconnect when sentinel changes to null', () => { const sentinel = document.createElement('div'); let currentSentinel: HTMLElement | null = sentinel; const getSentinelBottom = vi.fn(() => currentSentinel); const host = createLitElement(); const controller = createController({ host, getSentinelBottom, }); // First call with a real sentinel. controller.hostUpdated(); currentSentinel = null; controller.hostUpdated(); expect( vi.mocked(IntersectionObserver).mock.results[0].value.disconnect, ).toBeCalledTimes(2); expect( vi.mocked(IntersectionObserver).mock.results[0].value.observe, ).toBeCalledTimes(1); }); it('should skip disconnect/observe when sentinel is unchanged', () => { const sentinel = document.createElement('div'); const getSentinelBottom = vi.fn(() => sentinel); const host = createLitElement(); const controller = createController({ host, getSentinelBottom, }); controller.hostUpdated(); controller.hostUpdated(); // Only called once despite two hostUpdated() calls. expect( vi.mocked(IntersectionObserver).mock.results[0].value.disconnect, ).toBeCalledTimes(1); expect( vi.mocked(IntersectionObserver).mock.results[0].value.observe, ).toBeCalledTimes(1); }); it('should not observe when sentinel is null from the start', () => { const getSentinelBottom = vi.fn(() => null); const host = createLitElement(); const controller = createController({ host, getSentinelBottom, }); controller.hostUpdated(); expect( vi.mocked(IntersectionObserver).mock.results[0].value.disconnect, ).not.toBeCalled(); expect( vi.mocked(IntersectionObserver).mock.results[0].value.observe, ).not.toBeCalled(); }); }); it('should attach listeners on connect', () => { const host = createLitElement(); host.addEventListener = vi.fn(); const controller = createController({ host }); controller.hostConnected(); expect(vi.mocked(ResizeObserver).mock.results[0].value.observe).toBeCalledWith(host); expect(host.addEventListener).toBeCalledWith('wheel', expect.anything(), { passive: true, }); expect(host.addEventListener).toBeCalledWith('touchstart', expect.anything(), { passive: true, }); expect(host.addEventListener).toBeCalledWith('touchend', expect.anything()); expect(host.requestUpdate).toBeCalled(); }); it('should detach listeners on disconnect', () => { const host = createLitElement(); host.removeEventListener = vi.fn(); const controller = createController({ host }); controller.hostDisconnected(); expect(vi.mocked(ResizeObserver).mock.results[0].value.disconnect).toBeCalled(); expect( vi.mocked(IntersectionObserver).mock.results[0].value.disconnect, ).toBeCalled(); expect(host.removeEventListener).toBeCalledWith('wheel', expect.anything()); expect(host.removeEventListener).toBeCalledWith('touchstart', expect.anything()); expect(host.removeEventListener).toBeCalledWith('touchend', expect.anything()); }); describe('should set the number of columns', () => { it('should update columns based on columnWidth and host width', () => { const host = createLitElement(); Object.defineProperty(host, 'clientWidth', { value: 492 }); const controller = createController({ host }); controller.setOptions({ columnWidth: 250 }); expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe( '1', ); }); it('should calculate at least 1 column', () => { const host = createLitElement(); Object.defineProperty(host, 'clientWidth', { value: 100 }); const controller = createController({ host }); controller.setOptions({ columnWidth: 200 }); expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe( '1', ); }); describe('should respect column count round method', () => { it.each([ ['ceil' as const, 2], ['floor' as const, 1], ])( '%s', async ( columnCountRoundMethod: GalleryColumnCountRoundMethod, expectedColumns: number, ) => { const host = createLitElement(); Object.defineProperty(host, 'clientWidth', { value: 100 }); const controller = createController({ host }); controller.setOptions({ columnWidth: 51, columnCountRoundMethod }); expect( host.style.getPropertyValue('--advanced-camera-card-gallery-columns'), ).toBe(String(expectedColumns)); }, ); }); it('should not update columns when columnWidth is not set', () => { const host = createLitElement(); Object.defineProperty(host, 'clientWidth', { value: 500 }); const controller = createController({ host }); controller.setOptions({}); expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe( '', ); }); it('should update columns on resize', () => { const host = createLitElement(); const controller = createController({ host }); controller.setOptions({ columnWidth: 250 }); const width = 1000; Object.defineProperty(host, 'clientWidth', { value: width }); callResizeHandler([{ target: host, width, height: 200 }]); expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe( '4', ); }); }); describe('should extend gallery', () => { describe('should extend gallery up', () => { describe('should extend gallery up with wheel events', () => { it('should extend up with wheel event upwards', async () => { const host = createLitElement(); host.scrollTop = 0; const showLoaderTop = vi.fn(); const controller = createController({ host, showLoaderTopCallback: showLoaderTop, }); controller.setOptions({ extendUp: true }); controller.hostConnected(); const extendUpCallback = vi .fn() .mockImplementation((ev: CustomEvent) => { expect(showLoaderTop).toHaveBeenLastCalledWith(true); ev.detail.resolve(); }); host.addEventListener( 'advanced-camera-card:gallery:extend:up', extendUpCallback, ); host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 })); await flushPromises(); expect(showLoaderTop).toHaveBeenLastCalledWith(false); }); it('should not extend up with wheel event when not at top of component', async () => { const host = createLitElement(); host.scrollTop = 100; const showLoaderTop = vi.fn(); const controller = createController({ host, showLoaderTopCallback: showLoaderTop, }); controller.setOptions({ extendUp: true }); controller.hostConnected(); host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 })); expect(showLoaderTop).not.toHaveBeenCalled(); }); it('should not extend up with wheel event downwards', async () => { const host = createLitElement(); host.scrollTop = 0; const showLoaderTop = vi.fn(); const controller = createController({ host, showLoaderTopCallback: showLoaderTop, }); controller.setOptions({ extendUp: true }); controller.hostConnected(); host.dispatchEvent(new WheelEvent('wheel', { deltaY: 100 })); expect(showLoaderTop).not.toHaveBeenCalled(); }); }); describe('should extend gallery up with touch events', () => { it('should extend up with touch event upwards', async () => { const host = createLitElement(); const showLoaderTop = vi.fn(); const controller = createController({ host, showLoaderTopCallback: showLoaderTop, }); controller.setOptions({ extendUp: true }); controller.hostConnected(); const extendUpCallback = vi .fn() .mockImplementation((ev: CustomEvent) => { expect(showLoaderTop).toHaveBeenLastCalledWith(true); ev.detail.resolve(); }); host.addEventListener( 'advanced-camera-card:gallery:extend:up', extendUpCallback, ); host.dispatchEvent( createTouchEvent('touchstart', { touches: [createTouch({ screenY: 0 })] }), ); host.dispatchEvent( createTouchEvent('touchend', { changedTouches: [createTouch({ screenY: 100 })], }), ); await flushPromises(); expect(showLoaderTop).toHaveBeenLastCalledWith(false); }); it('should not extend up with multiple touches', async () => { const host = createLitElement(); const showLoaderTop = vi.fn(); const controller = createController({ host, showLoaderTopCallback: showLoaderTop, }); controller.setOptions({ extendUp: true }); controller.hostConnected(); host.dispatchEvent( createTouchEvent('touchstart', { touches: [createTouch({ screenY: 0 }), createTouch({ screenY: 0 })], }), ); host.dispatchEvent( createTouchEvent('touchend', { changedTouches: [createTouch({ screenY: 100 })], }), ); await flushPromises(); expect(showLoaderTop).not.toBeCalled(); }); it('should not extend up with touch when when not at top of component', async () => { const host = createLitElement(); host.scrollTop = 100; const showLoaderTop = vi.fn(); const controller = createController({ host, showLoaderTopCallback: showLoaderTop, }); controller.setOptions({ extendUp: true }); controller.hostConnected(); host.dispatchEvent( createTouchEvent('touchstart', { touches: [createTouch({ screenY: 0 }), createTouch({ screenY: 0 })], }), ); host.dispatchEvent( createTouchEvent('touchend', { changedTouches: [createTouch({ screenY: 100 })], }), ); await flushPromises(); expect(showLoaderTop).not.toBeCalled(); }); it('should not extend up with touch when touches moved downwards', async () => { const host = createLitElement(); const showLoaderTop = vi.fn(); const controller = createController({ host, showLoaderTopCallback: showLoaderTop, }); controller.setOptions({ extendUp: true }); controller.hostConnected(); host.dispatchEvent( createTouchEvent('touchstart', { touches: [createTouch({ screenY: 100 })], }), ); host.dispatchEvent( createTouchEvent('touchend', { changedTouches: [createTouch({ screenY: 0 })], }), ); await flushPromises(); expect(showLoaderTop).not.toBeCalled(); }); }); it('should not extend when extending up is disabled', async () => { const host = createLitElement(); const showLoaderTop = vi.fn(); const controller = createController({ host, showLoaderTopCallback: showLoaderTop, }); controller.setOptions({ extendUp: false }); controller.hostConnected(); host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 })); expect(showLoaderTop).not.toHaveBeenCalled(); }); it('should not artifically sleep if extend up call takes too long', async () => { vi.useFakeTimers(); const host = createLitElement(); host.scrollTop = 0; const showLoaderTop = vi.fn(); const controller = createController({ host, showLoaderTopCallback: showLoaderTop, }); controller.setOptions({ extendUp: true }); controller.hostConnected(); const extendUpCallback = vi .fn() .mockImplementation((ev: CustomEvent) => { vi.advanceTimersByTime(10000); ev.detail.resolve(); }); host.addEventListener( 'advanced-camera-card:gallery:extend:up', extendUpCallback, ); host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 })); await flushPromises(); expect(sleep).not.toHaveBeenCalled(); }); }); describe('should extend gallery down', () => { it('should extend down when sentinel intersects', async () => { const host = createLitElement(); const showSentinelBottom = vi.fn(); const controller = createController({ host, showSentinelBottomCallback: showSentinelBottom, }); controller.setOptions({ extendDown: true }); controller.hostConnected(); const extendDownCallback = vi .fn() .mockImplementation((ev: CustomEvent) => { expect(showSentinelBottom).toHaveBeenLastCalledWith(false); ev.detail.resolve(); }); host.addEventListener( 'advanced-camera-card:gallery:extend:down', extendDownCallback, ); callIntersectionHandler(true); await flushPromises(); expect(showSentinelBottom).toHaveBeenLastCalledWith(false); }); it('should not extend down when sentinel does not intersect', async () => { const host = createLitElement(); const showSentinelBottom = vi.fn(); const controller = createController({ host, showSentinelBottomCallback: showSentinelBottom, }); controller.setOptions({ extendUp: true }); controller.hostConnected(); callIntersectionHandler(false); expect(showSentinelBottom).not.toHaveBeenCalled(); }); it('should not extend when extending down is disabled', async () => { const host = createLitElement(); const showSentinelBottom = vi.fn(); const controller = createController({ host, showSentinelBottomCallback: showSentinelBottom, }); controller.setOptions({ extendUp: false }); controller.hostConnected(); callIntersectionHandler(true); expect(showSentinelBottom).not.toHaveBeenCalled(); }); }); }); describe('should handle content change', () => { describe('should scroll selected into view', () => { it('should scroll selected into view on first load', async () => { const selectedChild = document.createElement('div'); selectedChild.setAttribute('selected', ''); const slot = createSlot(); const host = createSlotHost({ slot, children: [ document.createElement('div'), document.createElement('div'), selectedChild, ], }); const controller = createController({ host, getSlot: () => slot, }); controller.updateContents(); expect(scrollIntoView).toBeCalledWith(selectedChild, { boundary: host, block: 'center', }); }); it('should do nothing on subsequent contents changes', async () => { const selectedChild = document.createElement('div'); selectedChild.setAttribute('selected', ''); const slot = createSlot(); const host = createSlotHost({ slot, children: [ document.createElement('div'), document.createElement('div'), selectedChild, ], }); const controller = createController({ host, getSlot: () => slot, }); controller.updateContents(); controller.updateContents(); controller.updateContents(); expect(scrollIntoView).toBeCalledTimes(1); }); it('should do nothing without a selected element', async () => { const slot = createSlot(); const host = createSlotHost({ slot, children: [ document.createElement('div'), document.createElement('div'), document.createElement('div'), ], }); const controller = createController({ host, getSlot: () => slot, }); controller.updateContents(); expect(scrollIntoView).not.toBeCalled(); }); }); it('should show bottom sentintel if contents change', async () => { const slot = createSlot(); const host = createSlotHost({ slot, }); const showSentinelBottom = vi.fn(); const controller = createController({ host, getSlot: () => slot, showSentinelBottomCallback: showSentinelBottom, }); controller.updateContents(); expect(showSentinelBottom).toBeCalledWith(true); }); it('should do nothing without slot', async () => { const slot = createSlot(); const host = createSlotHost({ slot, }); const showSentinelBottom = vi.fn(); const controller = createController({ host, showSentinelBottomCallback: showSentinelBottom, }); controller.updateContents(); expect(showSentinelBottom).not.toBeCalled(); expect(scrollIntoView).not.toBeCalled(); }); }); });