import { describe, it, expect, beforeEach, afterEach, vi, } from 'vitest'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import Item from '@/components/LinkItems/Item.vue'; import router from '@/router'; vi.mock('axios', () => ({ default: { get: vi.fn(() => Promise.resolve({ data: {} })) } })); vi.mock('@/router', () => ({ default: { push: vi.fn() } })); vi.mock('@/utils/ErrorHandler', () => ({ default: vi.fn() })); vi.mock('@/assets/interface-icons/interactive-editor-edit-mode.svg', () => ({ default: { template: '' }, })); const localVue = createLocalVue(); localVue.use(Vuex); localVue.directive('tooltip', {}); localVue.directive('longPress', {}); localVue.directive('clickOutside', {}); /** Factory — accepts overrides for item, props, appConfig, storeState, etc. */ function mountItem(overrides = {}) { const item = overrides.item || { id: 'test-1', title: 'Test Item', description: 'A test description', url: 'https://example.com', icon: 'fas fa-rocket', }; const mutations = { SET_MODAL_OPEN: vi.fn(), REMOVE_ITEM: vi.fn(), ...(overrides.mutations || {}), }; const storeState = { editMode: false, config: { appConfig: overrides.appConfig || {} }, ...(overrides.storeState || {}), }; const store = new Vuex.Store({ state: storeState, getters: { appConfig: (state) => state.config.appConfig, iconSize: () => overrides.iconSize || 'medium', getParentSectionOfItem: () => () => overrides.parentSection || { name: 'Default' }, }, mutations, }); const wrapper = shallowMount(Item, { localVue, store, propsData: { item, ...(overrides.props || {}) }, mocks: { $modal: { show: vi.fn(), hide: vi.fn() }, $toasted: { show: vi.fn() }, $t: (key) => key, ...(overrides.mocks || {}), }, stubs: { Icon: true, ItemOpenMethodIcon: true, StatusIndicator: true, ContextMenu: true, MoveItemTo: true, EditItem: true, EditModeIcon: true, }, }); return { wrapper, store, mutations }; } let openSpy; let clipboardSpy; beforeEach(() => { openSpy = vi.spyOn(window, 'open').mockImplementation(() => {}); clipboardSpy = vi.fn(() => Promise.resolve()); Object.defineProperty(navigator, 'clipboard', { value: { writeText: clipboardSpy }, writable: true, configurable: true, }); localStorage.getItem.mockReset(); localStorage.setItem.mockReset(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('Computed: itemIcon', () => { it('returns item.icon when set', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', icon: 'my-icon', }, }); expect(wrapper.vm.itemIcon).toBe('my-icon'); }); it('falls back to appConfig.defaultIcon', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#' }, appConfig: { defaultIcon: 'default-icon' }, }); expect(wrapper.vm.itemIcon).toBe('default-icon'); }); it('returns falsy when neither is set', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#' } }); expect(wrapper.vm.itemIcon).toBeFalsy(); }); }); describe('Computed: size', () => { it('returns valid itemSize prop', () => { const { wrapper } = mountItem({ props: { itemSize: 'large' } }); expect(wrapper.vm.size).toBe('large'); }); it('ignores invalid itemSize and falls back to store', () => { const { wrapper } = mountItem({ props: { itemSize: 'bogus' }, iconSize: 'small' }); expect(wrapper.vm.size).toBe('small'); }); it('falls back to store iconSize getter', () => { const { wrapper } = mountItem({ iconSize: 'small' }); expect(wrapper.vm.size).toBe('small'); }); it('defaults to medium', () => { const { wrapper } = mountItem(); expect(wrapper.vm.size).toBe('medium'); }); }); describe('Computed: makeColumnCount', () => { it.each([ [300, 1], [400, 2], [600, 3], [800, 4], [1100, 5], [1500, 0], ])('sectionWidth %i → %i columns', (width, expected) => { const { wrapper } = mountItem({ props: { sectionWidth: width } }); expect(wrapper.vm.makeColumnCount).toBe(expected); }); it('uses sectionDisplayData.itemCountX when set', () => { const { wrapper } = mountItem({ props: { sectionWidth: 300, sectionDisplayData: { itemCountX: 7 } }, }); expect(wrapper.vm.makeColumnCount).toBe(7); }); }); describe('Computed: makeClassList', () => { it('includes size-{size}', () => { const { wrapper } = mountItem({ props: { itemSize: 'small' } }); expect(wrapper.vm.makeClassList).toContain('size-small'); }); it('includes "short" when no icon', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#' } }); expect(wrapper.vm.makeClassList).toContain('short'); }); it('includes "add-new" when isAddNew', () => { const { wrapper } = mountItem({ props: { isAddNew: true } }); expect(wrapper.vm.makeClassList).toContain('add-new'); }); it('includes "is-edit-mode" when editMode is true', () => { const { wrapper } = mountItem({ storeState: { editMode: true, config: { appConfig: {} } } }); expect(wrapper.vm.makeClassList).toContain('is-edit-mode'); }); }); describe('Computed: enableStatusCheck', () => { it('item.statusCheck boolean overrides appConfig', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', statusCheck: false, }, appConfig: { statusCheck: true }, }); expect(wrapper.vm.enableStatusCheck).toBe(false); }); it('falls back to appConfig.statusCheck', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#' }, appConfig: { statusCheck: true }, }); expect(wrapper.vm.enableStatusCheck).toBe(true); }); it('defaults to false', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#' } }); expect(wrapper.vm.enableStatusCheck).toBe(false); }); }); describe('Computed: statusCheckInterval', () => { it('reads from item', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', statusCheckInterval: 30, }, }); expect(wrapper.vm.statusCheckInterval).toBe(30); }); it('falls back to appConfig', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#' }, appConfig: { statusCheckInterval: 15 }, }); expect(wrapper.vm.statusCheckInterval).toBe(15); }); it('clamps to max 60', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', statusCheckInterval: 120, }, }); expect(wrapper.vm.statusCheckInterval).toBe(60); }); it('clamps values less than 1 to 0', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', statusCheckInterval: 0.5, }, }); expect(wrapper.vm.statusCheckInterval).toBe(0); }); }); describe('Computed: accumulatedTarget', () => { it('uses item.target first', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', target: 'workspace', }, }); expect(wrapper.vm.accumulatedTarget).toBe('workspace'); }); it('falls back to appConfig.defaultOpeningMethod', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#' }, appConfig: { defaultOpeningMethod: 'sametab' }, }); expect(wrapper.vm.accumulatedTarget).toBe('sametab'); }); it('defaults to "newtab"', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#' } }); expect(wrapper.vm.accumulatedTarget).toBe('newtab'); }); }); describe('Computed: anchorTarget', () => { it.each([ ['sametab', '_self'], ['newtab', '_blank'], ['parent', '_parent'], ['top', '_top'], ['modal', undefined], ])('target "%s" → %s', (target, expected) => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', target, }, }); expect(wrapper.vm.anchorTarget).toBe(expected); }); it('returns _self in edit mode', () => { const { wrapper } = mountItem({ storeState: { editMode: true, config: { appConfig: {} } }, }); expect(wrapper.vm.anchorTarget).toBe('_self'); }); }); describe('Computed: hyperLinkHref', () => { it('returns "#" in edit mode', () => { const { wrapper } = mountItem({ storeState: { editMode: true, config: { appConfig: {} } }, }); expect(wrapper.vm.hyperLinkHref).toBe('#'); }); it.each(['modal', 'workspace', 'clipboard'])('returns "#" for %s target', (target) => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: 'https://x.com', target, }, }); expect(wrapper.vm.hyperLinkHref).toBe('#'); }); it('returns URL for normal targets', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: 'https://x.com', target: 'newtab', }, }); expect(wrapper.vm.hyperLinkHref).toBe('https://x.com'); }); }); describe('Computed: unicodeOpeningIcon', () => { it.each([ ['newtab', '"\\f360"'], ['sametab', '"\\f24d"'], ['parent', '"\\f3bf"'], ['top', '"\\f102"'], ['modal', '"\\f2d0"'], ['workspace', '"\\f0b1"'], ['clipboard', '"\\f0ea"'], ])('target "%s" → correct icon', (target, expected) => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', target, }, }); expect(wrapper.vm.unicodeOpeningIcon).toBe(expected); }); it('returns default icon for unknown target', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', target: 'unknown', }, }); expect(wrapper.vm.unicodeOpeningIcon).toBe('"\\f054"'); }); }); describe('Filter: shortUrl', () => { const { shortUrl } = Item.filters; it('extracts hostname from URL', () => { expect(shortUrl('https://www.example.com/path?q=1')).toBe('www.example.com'); }); it('handles IP addresses', () => { expect(shortUrl('192.168.1.1')).toBe('192.168.1.1'); }); it('returns empty string for falsy input', () => { expect(shortUrl(null)).toBe(''); expect(shortUrl(undefined)).toBe(''); expect(shortUrl('')).toBe(''); }); it('returns empty string for invalid input', () => { expect(shortUrl('not-a-url')).toBe(''); }); }); describe('Methods: getTooltipOptions', () => { it('returns empty object when no description or provider', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#' } }); expect(wrapper.vm.getTooltipOptions()).toEqual({}); }); it('includes description and provider in content', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', description: 'Desc', provider: 'Prov', }, }); const { content } = wrapper.vm.getTooltipOptions(); expect(content).toContain('Desc'); expect(content).toContain('Prov'); }); it('includes hotkey in content', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', description: 'D', hotkey: 3, }, }); const { content } = wrapper.vm.getTooltipOptions(); expect(content).toContain("'3'"); }); it('shows edit text in edit mode', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', description: 'D', }, storeState: { editMode: true, config: { appConfig: {} } }, }); expect(wrapper.vm.getTooltipOptions().content).toBe( 'interactive-editor.edit-section.edit-tooltip', ); }); it('placement is "left" when statusResponse exists', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: '#', description: 'D', }, }); wrapper.vm.statusResponse = { message: 'ok' }; expect(wrapper.vm.getTooltipOptions().placement).toBe('left'); }); }); describe('Methods: openItemSettings / closeEditMenu', () => { it('openItemSettings sets editMenuOpen, shows modal, commits SET_MODAL_OPEN', () => { const { wrapper, mutations } = mountItem(); wrapper.vm.openItemSettings(); expect(wrapper.vm.editMenuOpen).toBe(true); expect(wrapper.vm.$modal.show).toHaveBeenCalledWith('EDIT_ITEM'); expect(mutations.SET_MODAL_OPEN).toHaveBeenCalledWith(expect.anything(), true); }); it('closeEditMenu clears editMenuOpen, hides modal, commits SET_MODAL_OPEN(false)', () => { const { wrapper, mutations } = mountItem(); wrapper.vm.editMenuOpen = true; wrapper.vm.closeEditMenu(); expect(wrapper.vm.editMenuOpen).toBe(false); expect(wrapper.vm.$modal.hide).toHaveBeenCalledWith('EDIT_ITEM'); expect(mutations.SET_MODAL_OPEN).toHaveBeenCalledWith(expect.anything(), false); }); }); describe('Methods: openDeleteItem', () => { it('commits REMOVE_ITEM with correct payload', () => { const { wrapper, mutations } = mountItem({ parentSection: { name: 'MySection' } }); wrapper.vm.openDeleteItem(); expect(mutations.REMOVE_ITEM).toHaveBeenCalledWith( expect.anything(), { itemId: 'test-1', sectionName: 'MySection' }, ); }); }); describe('Methods: itemClicked', () => { const event = (extra = {}) => ({ preventDefault: vi.fn(), ctrlKey: false, altKey: false, ...extra, }); it('in edit mode: preventDefault + openItemSettings', () => { const { wrapper } = mountItem({ storeState: { editMode: true, config: { appConfig: {} } } }); const e = event(); const spy = vi.spyOn(wrapper.vm, 'openItemSettings'); wrapper.vm.itemClicked(e); expect(e.preventDefault).toHaveBeenCalled(); expect(spy).toHaveBeenCalled(); }); it('ctrl key: opens in new tab', () => { const { wrapper } = mountItem(); wrapper.vm.itemClicked(event({ ctrlKey: true })); expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank'); }); it('alt key: emits triggerModal', () => { const { wrapper } = mountItem(); wrapper.vm.itemClicked(event({ altKey: true })); expect(wrapper.emitted().triggerModal).toBeTruthy(); }); it('target modal: emits triggerModal', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: 'https://x.com', target: 'modal', }, }); wrapper.vm.itemClicked(event()); expect(wrapper.emitted().triggerModal).toBeTruthy(); }); it('target workspace: calls router.push', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: 'https://x.com', target: 'workspace', }, }); wrapper.vm.itemClicked(event()); expect(router.push).toHaveBeenCalledWith({ name: 'workspace', query: { url: 'https://x.com' } }); }); it('target clipboard: calls copyToClipboard', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: 'https://x.com', target: 'clipboard', }, }); const spy = vi.spyOn(wrapper.vm, 'copyToClipboard'); wrapper.vm.itemClicked(event()); expect(spy).toHaveBeenCalledWith('https://x.com'); }); it('always emits itemClicked', () => { const { wrapper } = mountItem(); wrapper.vm.itemClicked(event()); expect(wrapper.emitted().itemClicked).toBeTruthy(); }); it('skips smart-sort when disableSmartSort is set', () => { const { wrapper } = mountItem({ appConfig: { disableSmartSort: true } }); const spy = vi.spyOn(wrapper.vm, 'incrementMostUsedCount'); wrapper.vm.itemClicked(event()); expect(spy).not.toHaveBeenCalled(); }); }); describe('Methods: launchItem', () => { it.each([ ['newtab', '_blank'], ['sametab', '_self'], ])('%s calls window.open with %s', (method, target) => { const { wrapper } = mountItem(); wrapper.vm.launchItem(method, 'https://test.com'); expect(openSpy).toHaveBeenCalledWith('https://test.com', target); }); it('modal emits triggerModal', () => { const { wrapper } = mountItem(); wrapper.vm.launchItem('modal', 'https://test.com'); expect(wrapper.emitted().triggerModal[0]).toEqual(['https://test.com']); }); it('workspace calls router.push', () => { const { wrapper } = mountItem(); wrapper.vm.launchItem('workspace', 'https://test.com'); expect(router.push).toHaveBeenCalledWith({ name: 'workspace', query: { url: 'https://test.com' } }); }); it('clipboard calls copyToClipboard', () => { const { wrapper } = mountItem(); const spy = vi.spyOn(wrapper.vm, 'copyToClipboard'); wrapper.vm.launchItem('clipboard', 'https://test.com'); expect(spy).toHaveBeenCalledWith('https://test.com'); }); it('closes context menu', () => { const { wrapper } = mountItem(); wrapper.vm.contextMenuOpen = true; wrapper.vm.launchItem('newtab'); expect(wrapper.vm.contextMenuOpen).toBe(false); }); it('falls back to item.url when no link arg', () => { const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: 'https://fallback.com' }, }); wrapper.vm.launchItem('newtab'); expect(openSpy).toHaveBeenCalledWith('https://fallback.com', '_blank'); }); }); describe('Methods: openContextMenu / closeContextMenu', () => { it('toggles contextMenuOpen and sets position', () => { const { wrapper } = mountItem(); wrapper.vm.openContextMenu({ clientX: 100, clientY: 200 }); expect(wrapper.vm.contextMenuOpen).toBe(true); expect(wrapper.vm.contextPos.posX).toBe(100 + window.pageXOffset); expect(wrapper.vm.contextPos.posY).toBe(200 + window.pageYOffset); }); it('closeContextMenu sets false', () => { const { wrapper } = mountItem(); wrapper.vm.contextMenuOpen = true; wrapper.vm.closeContextMenu(); expect(wrapper.vm.contextMenuOpen).toBe(false); }); }); describe('Methods: copyToClipboard', () => { it('calls navigator.clipboard.writeText and shows toast', () => { const { wrapper } = mountItem(); wrapper.vm.copyToClipboard('hello'); expect(clipboardSpy).toHaveBeenCalledWith('hello'); expect(wrapper.vm.$toasted.show).toHaveBeenCalled(); }); it('shows error when clipboard unavailable', async () => { const ErrorHandler = (await import('@/utils/ErrorHandler')).default; Object.defineProperty(navigator, 'clipboard', { value: undefined, writable: true, configurable: true, }); const { wrapper } = mountItem(); wrapper.vm.copyToClipboard('hello'); expect(ErrorHandler).toHaveBeenCalled(); expect(wrapper.vm.$toasted.show).toHaveBeenCalledWith( 'Unable to copy, see log', expect.objectContaining({ className: 'toast-error' }), ); }); }); describe('Methods: incrementMostUsedCount / incrementLastUsedCount', () => { it('increments existing count', () => { localStorage.getItem.mockReturnValue(JSON.stringify({ 'item-1': 5 })); const { wrapper } = mountItem(); wrapper.vm.incrementMostUsedCount('item-1'); const saved = JSON.parse(localStorage.setItem.mock.calls[0][1]); expect(saved['item-1']).toBe(6); }); it('initializes new items to 1', () => { localStorage.getItem.mockReturnValue('{}'); const { wrapper } = mountItem(); wrapper.vm.incrementMostUsedCount('new-item'); const saved = JSON.parse(localStorage.setItem.mock.calls[0][1]); expect(saved['new-item']).toBe(1); }); it('writes last-used timestamp', () => { localStorage.getItem.mockReturnValue('{}'); const { wrapper } = mountItem(); const before = Date.now(); wrapper.vm.incrementLastUsedCount('item-1'); const saved = JSON.parse(localStorage.setItem.mock.calls[0][1]); expect(saved['item-1']).toBeGreaterThanOrEqual(before); }); }); describe('Lifecycle: mounted', () => { it('calls checkWebsiteStatus when enableStatusCheck is true', () => { const spy = vi.spyOn(Item.mixins[0].methods, 'checkWebsiteStatus'); mountItem({ item: { id: '1', title: 'X', url: 'https://x.com', statusCheck: true, }, }); expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); it('sets up interval when statusCheckInterval > 0', () => { vi.useFakeTimers(); const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: 'https://x.com', statusCheck: true, statusCheckInterval: 5, }, }); expect(wrapper.vm.intervalId).toBeDefined(); vi.useRealTimers(); }); it('does nothing when statusCheck disabled', () => { const spy = vi.spyOn(Item.mixins[0].methods, 'checkWebsiteStatus'); mountItem({ item: { id: '1', title: 'X', url: 'https://x.com', statusCheck: false, }, }); expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); }); }); describe('Lifecycle: beforeDestroy', () => { it('clears interval if intervalId exists', () => { vi.useFakeTimers(); const clearSpy = vi.spyOn(global, 'clearInterval'); const { wrapper } = mountItem({ item: { id: '1', title: 'X', url: 'https://x.com', statusCheck: true, statusCheckInterval: 5, }, }); const { intervalId } = wrapper.vm; wrapper.destroy(); expect(clearSpy).toHaveBeenCalledWith(intervalId); vi.useRealTimers(); }); }); describe('Template rendering', () => { it('renders item title and description', () => { const { wrapper } = mountItem(); expect(wrapper.find('.text').text()).toBe('Test Item'); expect(wrapper.find('.description').text()).toBe('A test description'); }); it('has correct wrapper classes', () => { const { wrapper } = mountItem({ props: { itemSize: 'large', sectionWidth: 800 } }); const div = wrapper.find('.item-wrapper'); expect(div.classes()).toContain('wrap-size-large'); expect(div.classes()).toContain('span-4'); }); it('shows StatusIndicator only when enableStatusCheck', () => { const { wrapper: off } = mountItem({ item: { id: '1', title: 'X', url: '#', statusCheck: false, }, }); expect(off.find('statusindicator-stub').exists()).toBe(false); const { wrapper: on } = mountItem({ item: { id: '1', title: 'X', url: '#', statusCheck: true, }, }); expect(on.find('statusindicator-stub').exists()).toBe(true); }); it('shows EditModeIcon only in edit mode', () => { const { wrapper: normal } = mountItem(); expect(normal.find('editmodeicon-stub').exists()).toBe(false); const { wrapper: editing } = mountItem({ storeState: { editMode: true, config: { appConfig: {} } }, }); expect(editing.find('editmodeicon-stub').exists()).toBe(true); }); it('sets correct id on anchor', () => { const { wrapper } = mountItem(); expect(wrapper.find('a.item').attributes('id')).toBe('link-test-1'); }); });