import { getLocalVue } from "@tests/vitest/helpers"; import { mount, type Wrapper } from "@vue/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { nextTick } from "vue"; import ScrollList from "./ScrollList.vue"; interface TestItem { id: string; name: string; } const TOTAL_ITEMS = 50; const BUFFER_SIZE = 5; const TEST_ITEM_DIV = "div[data-description='test item']"; const LOAD_MORE_BUTTON = "[data-description='load more items button']"; const TEST_ITEM_SLOT = '
Test {{ item.name }}
'; const ITEM_NAME = "test item"; const ITEM_NAME_PLURAL = "test items"; function createTestItems(count: number): TestItem[] { const items = Array.from({ length: count }, (_, index) => ({ id: `item-${index}`, name: `Item ${index + 1}`, })); return items; } /** The infinite scroll callback mock, given the same name as the callback in `ScrollList`. */ let loadItems: (() => void) | null = null; // Mock useInfiniteScroll to store the component's callback in the callback here vi.mock("@vueuse/core", async () => ({ ...(await vi.importActual("@vueuse/core")), useInfiniteScroll: vi.fn((element, callback) => { // On component mount, `useInfiniteScroll` is called and here, we store the callback loadItems = callback; return {}; }), })); /** Mocks a single scroll event to trigger the infinite scroll callback. */ async function scrollOnce() { if (loadItems) { loadItems(); await new Promise((resolve) => setTimeout(resolve, 10)); await nextTick(); } } const TEST_ITEMS = createTestItems(TOTAL_ITEMS); /** For the specific case where `propItems` is passed and updated by the loader, * we keep track of the expected total count to calculate changes that are unrelated * to scrolling/loading (e.g., an item or more added/removed externally in some other component). */ let expectedTotalItemCount = 0; /** Returns `BUFFER_SIZE` items each time, given the current offset. * * Note: _The_ `wrapper` _parameter is optional and we use it to update `props.propItems`. * This serves to mock the behavior of the parent component updating the `propItems` (via a store for e.g.)._ * @param offset The current offset to load items from. * @param limit The number of items to load. * @param wrapper Optional component wrapper to update the `propItems` with new items. */ const testLoader = vi.fn( (offset: number, limit: number, wrapper?: Wrapper): Promise<{ items: TestItem[]; total: number }> => { const newItems = TEST_ITEMS.slice(offset, offset + limit); if (wrapper) { const currentPropItems = wrapper.props().propItems || []; // Calculate external changes: actual length vs what we expected before this load const externalChanges = currentPropItems.length - expectedTotalItemCount; // Update expected length for next call expectedTotalItemCount = currentPropItems.length + newItems.length; wrapper.setProps({ propItems: [...(wrapper.props().propItems || []), ...newItems], propTotalCount: TOTAL_ITEMS + externalChanges, }); } return Promise.resolve({ items: newItems, total: TOTAL_ITEMS, }); }, ); describe("ScrollList with local loader and data", () => { let wrapper: Wrapper; beforeEach(async () => { testLoader.mockClear(); wrapper = mount(ScrollList as object, { propsData: { loader: (offset: number, limit: number) => testLoader(offset, limit), itemKey: (item: TestItem) => item.id, limit: BUFFER_SIZE, name: "test item", namePlural: "test items", }, localVue: getLocalVue(), scopedSlots: { item: TEST_ITEM_SLOT, }, stubs: { FontAwesomeIcon: true, }, }); }); it("loads items on scroll", async () => { await scrollOnce(); // First `BUFFER_SIZE` items should be loaded const items = wrapper.findAll(TEST_ITEM_DIV); expect(items.length).toBe(BUFFER_SIZE); // Next, we scroll twice more to find a total of `BUFFER_SIZE * 3` items await scrollOnce(); await scrollOnce(); expect(wrapper.findAll(TEST_ITEM_DIV).length).toBe(BUFFER_SIZE * 3); // Confirm that that the loader (testLoader) was called thrice (since we call `scrollOnce` 3 times) expect(testLoader).toHaveBeenCalledTimes(3); // Then we scroll until all items are loaded while (wrapper.findAll(TEST_ITEM_DIV).length < TOTAL_ITEMS) { await scrollOnce(); } expect(wrapper.findAll(TEST_ITEM_DIV).length).toBe(TOTAL_ITEMS); // Confirm that the loader was called enough times to load all items expect(testLoader).toHaveBeenCalledTimes(Math.ceil(TOTAL_ITEMS / BUFFER_SIZE)); // Check that even if we scroll again, no new items are loaded await scrollOnce(); expect(wrapper.findAll(TEST_ITEM_DIV).length).toBe(TOTAL_ITEMS); expect(testLoader).toHaveBeenCalledTimes(Math.ceil(TOTAL_ITEMS / BUFFER_SIZE)); }); it("shows item count and total items", async () => { await scrollOnce(); expect(wrapper.text()).toContain(`Loaded ${BUFFER_SIZE} out of ${TOTAL_ITEMS} ${ITEM_NAME_PLURAL}`); await scrollOnce(); await scrollOnce(); expect(wrapper.text()).toContain(`Loaded ${BUFFER_SIZE * 3} out of ${TOTAL_ITEMS} ${ITEM_NAME_PLURAL}`); expect(wrapper.find(LOAD_MORE_BUTTON).exists()).toBe(true); while (wrapper.findAll(TEST_ITEM_DIV).length < TOTAL_ITEMS) { await scrollOnce(); } expect(wrapper.text()).toContain(`- All ${ITEM_NAME_PLURAL} loaded -`); expect(wrapper.find(LOAD_MORE_BUTTON).exists()).toBe(false); // Now we test if the `showCountInFooter` prop works await wrapper.setProps({ showCountInFooter: true }); expect(wrapper.text()).toContain(`- ${TOTAL_ITEMS} ${ITEM_NAME_PLURAL} loaded -`); }); }); describe("ScrollList with prop items and no local state", () => { let wrapper: Wrapper; beforeEach(() => { testLoader.mockClear(); wrapper = mount(ScrollList as object, { propsData: { propItems: TEST_ITEMS, propTotalCount: TOTAL_ITEMS, itemKey: (item: TestItem) => item.id, name: ITEM_NAME, namePlural: ITEM_NAME_PLURAL, }, localVue: getLocalVue(), scopedSlots: { item: TEST_ITEM_SLOT, }, stubs: { FontAwesomeIcon: true, }, }); }); it("renders all items without scrolling/loading", async () => { // Assert that `propItems` is already populated expect(wrapper.props().propItems.length).toBe(TOTAL_ITEMS); expect(wrapper.findAll(TEST_ITEM_DIV).length).toBe(TOTAL_ITEMS); expect(wrapper.text()).toContain(`All ${ITEM_NAME_PLURAL} loaded`); expect(wrapper.find(LOAD_MORE_BUTTON).exists()).toBe(false); // We try to scroll, but since there are no items to load, the loader should not be called await scrollOnce(); expect(testLoader).toHaveBeenCalledTimes(0); }); }); describe("ScrollList with prop items and a local state loader", () => { let wrapper: Wrapper; beforeEach(() => { testLoader.mockClear(); expectedTotalItemCount = 0; wrapper = mount(ScrollList as object, { propsData: { // We make sure the `loader` updates the `propItems` (mock the loader loading via a pinia store for e.g.) loader: (offset: number, limit: number) => testLoader(offset, limit, wrapper), limit: BUFFER_SIZE, propItems: [], propTotalCount: TOTAL_ITEMS, itemKey: (item: TestItem) => item.id, name: ITEM_NAME, namePlural: ITEM_NAME_PLURAL, adjustForTotalCountChanges: false, // Default; we will adjust this to test this later }, localVue: getLocalVue(), scopedSlots: { item: TEST_ITEM_SLOT, }, stubs: { FontAwesomeIcon: true, }, }); }); it("updates the propItems on scroll", async () => { // Assert that `propItems` is initially empty expect(wrapper.props().propItems.length).toBe(0); await scrollOnce(); // First `BUFFER_SIZE` items should be loaded expect(wrapper.findAll(TEST_ITEM_DIV).length).toBe(BUFFER_SIZE); // And the `propItems` have been updated as well expect(wrapper.props().propItems.length).toBe(BUFFER_SIZE); // And this happened through the loader expect(testLoader).toHaveBeenCalledTimes(1); // Next, we scroll twice more to find a total of `BUFFER_SIZE * 3` items await scrollOnce(); await scrollOnce(); expect(wrapper.findAll(TEST_ITEM_DIV).length).toBe(BUFFER_SIZE * 3); expect(wrapper.props().propItems.length).toBe(BUFFER_SIZE * 3); expect(testLoader).toHaveBeenCalledTimes(3); }); it("handles the count change discrepancy between propItems and local state", async () => { // Initially, propItems is empty, so the count should be 0 expect(wrapper.text()).toContain(`Loaded 0 out of ${TOTAL_ITEMS} ${ITEM_NAME_PLURAL}`); await scrollOnce(); // After loading first BUFFER_SIZE items, the count should be updated via the loader expect(wrapper.text()).toContain(`Loaded ${BUFFER_SIZE} out of ${TOTAL_ITEMS} ${ITEM_NAME_PLURAL}`); expect(testLoader).toHaveBeenCalledTimes(1); // Mock a change in the propItems (e.g., a store update like added/removed item) which is unrelated to scroll fetching // and DOESN'T UPDATE `propTotalCount` await wrapper.setProps({ propItems: [...(wrapper.props().propItems || []), { id: "extra-item", name: "Extra Item" }], }); // Confirm that this did not happen via the loader expect(testLoader).toHaveBeenCalledTimes(1); // The count is initially not handled correctly; current count updates but total count remains the same expect(wrapper.text()).toContain(`Loaded ${BUFFER_SIZE + 1} out of ${TOTAL_ITEMS} ${ITEM_NAME_PLURAL}`); // Now we trigger the adjustment for total count changes await wrapper.setProps({ adjustForTotalCountChanges: true }); // The count should now reflect the total items correctly expect(wrapper.text()).toContain(`Loaded ${BUFFER_SIZE + 1} out of ${TOTAL_ITEMS + 1} ${ITEM_NAME_PLURAL}`); // Scroll again to load more items and check the count await scrollOnce(); expect(testLoader).toHaveBeenCalledTimes(2); // This is a very important assertion: // It confirms that after the external change, the loader loads the correct number of items // from the correct offset, and that the loader's total count is adjusted correctly // meaning there is no discrepancy between the propItems and the local state's counts. // (So the `adjustForTotalCountChanges` did not come into play here). expect(wrapper.text()).toContain(`Loaded ${BUFFER_SIZE * 2 + 1} out of ${TOTAL_ITEMS + 1} ${ITEM_NAME_PLURAL}`); // We confirm that the adjustment based on `adjustForTotalCountChanges` in the `totalItemCount` computed was not calculated // here, since localItems and propItems are in sync. await wrapper.setProps({ adjustForTotalCountChanges: false }); expect(wrapper.text()).toContain(`Loaded ${BUFFER_SIZE * 2 + 1} out of ${TOTAL_ITEMS + 1} ${ITEM_NAME_PLURAL}`); }); });