diff --git a/src/components/atoms/ActionButton.test.ts b/src/components/atoms/ActionButton.test.ts new file mode 100644 index 00000000..d5ddb684 --- /dev/null +++ b/src/components/atoms/ActionButton.test.ts @@ -0,0 +1,176 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import ActionButton from '@/components/atoms/ActionButton.vue' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + useRoute: () => ({ + path: '/test', + }), + RouterLink: { + template: '', + props: ['to'], + }, +})) + +// Mock analytics +vi.mock('@/utils/analytics', () => ({ + trackButtonClick: vi.fn(), +})) + +describe('ActionButton', () => { + it('renders as button by default', () => { + const wrapper = mount(ActionButton, { + slots: { + default: 'Click me', + }, + }) + + const button = wrapper.find('button') + expect(button.exists()).toBe(true) + expect(button.text()).toBe('Click me') + }) + + it('renders as link when href is provided', () => { + const wrapper = mount(ActionButton, { + props: { + href: 'https://example.com', + }, + slots: { + default: 'Link text', + }, + }) + + const link = wrapper.find('a') + expect(link.exists()).toBe(true) + expect(link.attributes('href')).toBe('https://example.com') + }) + + it('renders as RouterLink when to is provided', () => { + const wrapper = mount(ActionButton, { + props: { + to: '/home', + }, + slots: { + default: 'Router Link', + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + expect(wrapper.html()).toContain('Router Link') + }) + + it('applies primary variant classes by default', () => { + const wrapper = mount(ActionButton, { + slots: { + default: 'Button', + }, + }) + + const button = wrapper.find('button') + expect(button.classes()).toContain('bg-primary') + }) + + it('applies secondary variant classes when specified', () => { + const wrapper = mount(ActionButton, { + props: { + variant: 'secondary', + }, + slots: { + default: 'Button', + }, + }) + + const button = wrapper.find('button') + expect(button.classes()).toContain('border-primary') + }) + + it('disables button when disabled prop is true', () => { + const wrapper = mount(ActionButton, { + props: { + disabled: true, + }, + slots: { + default: 'Disabled', + }, + }) + + const button = wrapper.find('button') + expect(button.attributes('disabled')).toBeDefined() + }) + + it('calls trackButtonClick on click', async () => { + const { trackButtonClick } = await import('@/utils/analytics') + + const wrapper = mount(ActionButton, { + props: { + contentSection: 'test-section', + }, + slots: { + default: 'Click me', + }, + }) + + await wrapper.find('button').trigger('click') + + expect(trackButtonClick).toHaveBeenCalledWith({ + button_name: 'Click me', + content_section: 'test-section', + link_category: '/test', + }) + }) + + it('applies size classes correctly', () => { + const wrapper = mount(ActionButton, { + props: { + size: 'lg', + }, + slots: { + default: 'Large Button', + }, + }) + + const button = wrapper.find('button') + expect(button.classes()).toContain('px-4') + expect(button.classes()).toContain('py-2') + }) + + it('supports download attribute for links', () => { + const wrapper = mount(ActionButton, { + props: { + href: 'https://example.com/file.pdf', + download: true, + }, + slots: { + default: 'Download', + }, + }) + + const link = wrapper.find('a') + expect(link.attributes('download')).toBeDefined() + }) + + it('supports target and rel attributes for links', () => { + const wrapper = mount(ActionButton, { + props: { + href: 'https://example.com', + target: '_blank', + rel: 'noopener noreferrer', + }, + slots: { + default: 'External Link', + }, + }) + + const link = wrapper.find('a') + expect(link.attributes('target')).toBe('_blank') + expect(link.attributes('rel')).toBe('noopener noreferrer') + }) +}) diff --git a/src/components/atoms/BackButton.test.ts b/src/components/atoms/BackButton.test.ts new file mode 100644 index 00000000..d76e800f --- /dev/null +++ b/src/components/atoms/BackButton.test.ts @@ -0,0 +1,88 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import BackButton from '@/components/atoms/BackButton.vue' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + useRoute: () => ({ + path: '/test', + }), +})) + +// Mock analytics +vi.mock('@/utils/analytics', () => ({ + trackButtonClick: vi.fn(), +})) + +describe('BackButton', () => { + it('renders with default label', () => { + const wrapper = mount(BackButton, { + global: { + stubs: { + ActionButton: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Back') + }) + + it('renders with custom label', () => { + const wrapper = mount(BackButton, { + props: { + label: 'Go Back', + }, + global: { + stubs: { + ActionButton: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Go Back') + }) + + it('calls onClick callback when clicked', async () => { + const onClickMock = vi.fn() + const wrapper = mount(BackButton, { + props: { + onClick: onClickMock, + }, + global: { + stubs: { + ActionButton: { + template: '', + }, + }, + }, + }) + + await wrapper.find('button').trigger('click') + + expect(onClickMock).toHaveBeenCalled() + }) + + it('passes contentSection prop to ActionButton', () => { + const wrapper = mount(BackButton, { + props: { + contentSection: 'test-section', + }, + global: { + stubs: { + ActionButton: { + name: 'ActionButton', + template: '', + props: ['contentSection'], + }, + }, + }, + }) + + const button = wrapper.findComponent({ name: 'ActionButton' }) + expect(button.props('contentSection')).toBe('test-section') + }) +}) diff --git a/src/components/atoms/BackToTop.test.ts b/src/components/atoms/BackToTop.test.ts new file mode 100644 index 00000000..9b9cb7a2 --- /dev/null +++ b/src/components/atoms/BackToTop.test.ts @@ -0,0 +1,101 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import BackToTop from '@/components/atoms/BackToTop.vue' + +describe('BackToTop', () => { + beforeEach(() => { + // Reset window scroll position + window.scrollY = 0 + vi.clearAllMocks() + }) + + it('does not render button when not visible initially', () => { + const wrapper = mount(BackToTop, { + global: { + stubs: { + ArrowUpIcon: true, + }, + }, + }) + + const button = wrapper.find('button') + expect(button.exists()).toBe(false) + }) + + it('shows button when scrolled down more than 300px', async () => { + const wrapper = mount(BackToTop, { + global: { + stubs: { + ArrowUpIcon: true, + }, + }, + }) + + // Simulate scroll + Object.defineProperty(window, 'scrollY', { value: 400, writable: true }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + + expect((wrapper.vm as any).isVisible).toBe(true) + }) + + it('hides button when scrolled less than 300px', async () => { + const wrapper = mount(BackToTop, { + global: { + stubs: { + ArrowUpIcon: true, + }, + }, + }) + + // Simulate scroll + Object.defineProperty(window, 'scrollY', { value: 200, writable: true }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + + expect((wrapper.vm as any).isVisible).toBe(false) + }) + + it('scrolls to top when button is clicked', async () => { + const scrollToMock = vi.fn() + window.scrollTo = scrollToMock + + const wrapper = mount(BackToTop, { + global: { + stubs: { + ArrowUpIcon: true, + }, + }, + }) + + // Make button visible + Object.defineProperty(window, 'scrollY', { value: 400, writable: true }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + + await wrapper.find('button').trigger('click') + + expect(scrollToMock).toHaveBeenCalledWith({ + top: 0, + behavior: 'smooth', + }) + }) + + it('has proper aria-label', async () => { + const wrapper = mount(BackToTop, { + global: { + stubs: { + ArrowUpIcon: true, + }, + }, + }) + + // Make button visible + Object.defineProperty(window, 'scrollY', { value: 400, writable: true }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + + const button = wrapper.find('button') + expect(button.attributes('aria-label')).toBe('Back to top') + }) +}) diff --git a/src/components/atoms/CloseButton.test.ts b/src/components/atoms/CloseButton.test.ts new file mode 100644 index 00000000..249912f9 --- /dev/null +++ b/src/components/atoms/CloseButton.test.ts @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import CloseButton from '@/components/atoms/CloseButton.vue' + +describe('CloseButton', () => { + it('renders close button', () => { + const wrapper = mount(CloseButton) + + const button = wrapper.find('button') + expect(button.exists()).toBe(true) + }) + + it('emits click event when clicked', async () => { + const wrapper = mount(CloseButton) + + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('click')).toBeTruthy() + expect(wrapper.emitted('click')?.[0]).toBeTruthy() + }) + + it('has proper aria-label', () => { + const wrapper = mount(CloseButton) + + const button = wrapper.find('button') + expect(button.attributes('aria-label')).toBe('Close') + }) + + it('has button type', () => { + const wrapper = mount(CloseButton) + + const button = wrapper.find('button') + expect(button.attributes('type')).toBe('button') + }) + + it('displays close symbol', () => { + const wrapper = mount(CloseButton) + + expect(wrapper.text()).toContain('✕') + }) + + it('applies hover styles via classes', () => { + const wrapper = mount(CloseButton) + + const button = wrapper.find('button') + expect(button.classes()).toContain('hover:opacity-70') + expect(button.classes()).toContain('transition-opacity') + expect(button.classes()).toContain('cursor-pointer') + }) +}) diff --git a/src/components/atoms/CodeBlock.test.ts b/src/components/atoms/CodeBlock.test.ts new file mode 100644 index 00000000..1797b8e1 --- /dev/null +++ b/src/components/atoms/CodeBlock.test.ts @@ -0,0 +1,150 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CodeBlock from '@/components/atoms/CodeBlock.vue' + +// Mock Prism +vi.mock('prismjs', () => ({ + default: { + highlightElement: vi.fn(), + }, +})) + +vi.mock('prismjs/components/prism-markup', () => ({})) +vi.mock('prismjs/components/prism-css', () => ({})) +vi.mock('prismjs/components/prism-javascript', () => ({})) +vi.mock('prismjs/components/prism-python', () => ({})) +vi.mock('prismjs/components/prism-markdown', () => ({})) +vi.mock('prismjs/plugins/line-numbers/prism-line-numbers.css', () => ({})) +vi.mock('prismjs/plugins/line-numbers/prism-line-numbers', () => ({})) + +describe('CodeBlock', () => { + beforeEach(() => { + // Mock window.matchMedia + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) + }) + + it('renders code block with filename', () => { + const wrapper = mount(CodeBlock, { + props: { + code: 'const x = 1;', + filename: 'test.js', + }, + global: { + stubs: { + CopyButton: true, + }, + }, + }) + + expect(wrapper.find('code').exists()).toBe(true) + }) + + it('detects JavaScript language from .js extension', () => { + const wrapper = mount(CodeBlock, { + props: { + code: 'const x = 1;', + filename: 'test.js', + }, + global: { + stubs: { + CopyButton: true, + }, + }, + }) + + expect((wrapper.vm as any).detectedLanguage).toBe('javascript') + }) + + it('detects Python language from .py extension', () => { + const wrapper = mount(CodeBlock, { + props: { + code: 'x = 1', + filename: 'test.py', + }, + global: { + stubs: { + CopyButton: true, + }, + }, + }) + + expect((wrapper.vm as any).detectedLanguage).toBe('python') + }) + + it('detects markup language from .html extension', () => { + const wrapper = mount(CodeBlock, { + props: { + code: '
test
', + filename: 'test.html', + }, + global: { + stubs: { + CopyButton: true, + }, + }, + }) + + expect((wrapper.vm as any).detectedLanguage).toBe('markup') + }) + + it('detects CSS language from .css extension', () => { + const wrapper = mount(CodeBlock, { + props: { + code: 'body { color: red; }', + filename: 'test.css', + }, + global: { + stubs: { + CopyButton: true, + }, + }, + }) + + expect((wrapper.vm as any).detectedLanguage).toBe('css') + }) + + it('returns none for unknown extensions', () => { + const wrapper = mount(CodeBlock, { + props: { + code: 'some code', + filename: 'test.unknown', + }, + global: { + stubs: { + CopyButton: true, + }, + }, + }) + + expect((wrapper.vm as any).detectedLanguage).toBe('none') + }) + + it('displays the code content', () => { + const testCode = 'const greeting = "Hello World";' + const wrapper = mount(CodeBlock, { + props: { + code: testCode, + filename: 'test.js', + }, + global: { + stubs: { + CopyButton: true, + }, + }, + }) + + expect(wrapper.text()).toContain(testCode) + }) +}) diff --git a/src/components/atoms/CopyButton.test.ts b/src/components/atoms/CopyButton.test.ts new file mode 100644 index 00000000..8407d6ae --- /dev/null +++ b/src/components/atoms/CopyButton.test.ts @@ -0,0 +1,125 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CopyButton from '@/components/atoms/CopyButton.vue' + +describe('CopyButton', () => { + const mockClipboard = { + writeText: vi.fn(), + } + + beforeEach(() => { + Object.assign(navigator, { + clipboard: mockClipboard, + }) + mockClipboard.writeText.mockResolvedValue(undefined) + vi.clearAllMocks() + }) + + it('renders copy button', () => { + const wrapper = mount(CopyButton, { + props: { + text: 'test text', + }, + global: { + stubs: { + CopyIcon: true, + }, + }, + }) + + const button = wrapper.find('button') + expect(button.exists()).toBe(true) + }) + + it('copies text to clipboard when clicked', async () => { + const testText = 'Copy this text' + const wrapper = mount(CopyButton, { + props: { + text: testText, + }, + global: { + stubs: { + CopyIcon: true, + }, + }, + }) + + await wrapper.find('button').trigger('click') + + expect(mockClipboard.writeText).toHaveBeenCalledWith(testText) + }) + + it('shows "Copied!" message after successful copy', async () => { + const wrapper = mount(CopyButton, { + props: { + text: 'test', + }, + global: { + stubs: { + CopyIcon: true, + }, + }, + }) + + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Copied!') + }) + + it('uses custom title prop', () => { + const wrapper = mount(CopyButton, { + props: { + text: 'test', + title: 'Copy Code', + }, + global: { + stubs: { + CopyIcon: true, + }, + }, + }) + + const button = wrapper.find('button') + expect(button.attributes('title')).toBe('Copy Code') + }) + + it('defaults to "Copy" title when not specified', () => { + const wrapper = mount(CopyButton, { + props: { + text: 'test', + }, + global: { + stubs: { + CopyIcon: true, + }, + }, + }) + + const button = wrapper.find('button') + expect(button.attributes('title')).toBe('Copy') + }) + + it('handles copy failure gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockClipboard.writeText.mockRejectedValue(new Error('Copy failed')) + + const wrapper = mount(CopyButton, { + props: { + text: 'test', + }, + global: { + stubs: { + CopyIcon: true, + }, + }, + }) + + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + expect(consoleErrorSpy).toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) +}) diff --git a/src/components/atoms/LoadingBox.test.ts b/src/components/atoms/LoadingBox.test.ts new file mode 100644 index 00000000..ab4433a9 --- /dev/null +++ b/src/components/atoms/LoadingBox.test.ts @@ -0,0 +1,36 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import LoadingBox from '@/components/atoms/LoadingBox.vue' + +describe('LoadingBox', () => { + it('renders with default message', () => { + const wrapper = mount(LoadingBox) + + expect(wrapper.text()).toBe('Loading...') + }) + + it('renders with custom message', () => { + const customMessage = 'Please wait...' + const wrapper = mount(LoadingBox, { + props: { + message: customMessage, + }, + }) + + expect(wrapper.text()).toBe(customMessage) + }) + + it('has box class', () => { + const wrapper = mount(LoadingBox) + + const div = wrapper.find('div') + expect(div.classes()).toContain('box') + }) + + it('has text-center class', () => { + const wrapper = mount(LoadingBox) + + const div = wrapper.find('div') + expect(div.classes()).toContain('text-center') + }) +}) diff --git a/src/components/molecules/ErrorBlock.test.ts b/src/components/molecules/ErrorBlock.test.ts new file mode 100644 index 00000000..e46e791d --- /dev/null +++ b/src/components/molecules/ErrorBlock.test.ts @@ -0,0 +1,55 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import ErrorBlock from '@/components/molecules/ErrorBlock.vue' + +describe('ErrorBlock', () => { + it('renders error title and message', () => { + const wrapper = mount(ErrorBlock, { + props: { + title: 'Error Title', + error: 'This is an error message', + }, + }) + + expect(wrapper.text()).toContain('Error Title') + expect(wrapper.text()).toContain('This is an error message') + }) + + it('displays title in h3 tag', () => { + const wrapper = mount(ErrorBlock, { + props: { + title: 'Test Error', + error: 'Error details', + }, + }) + + const h3 = wrapper.find('h3') + expect(h3.exists()).toBe(true) + expect(h3.text()).toBe('Test Error') + }) + + it('displays error in p tag', () => { + const wrapper = mount(ErrorBlock, { + props: { + title: 'Error', + error: 'Error message content', + }, + }) + + const p = wrapper.find('p') + expect(p.exists()).toBe(true) + expect(p.text()).toBe('Error message content') + }) + + it('has error-box class', () => { + const wrapper = mount(ErrorBlock, { + props: { + title: 'Error', + error: 'Message', + }, + }) + + const div = wrapper.find('div') + expect(div.classes()).toContain('error-box') + }) +}) diff --git a/src/components/molecules/ListContent.test.ts b/src/components/molecules/ListContent.test.ts new file mode 100644 index 00000000..39210d7e --- /dev/null +++ b/src/components/molecules/ListContent.test.ts @@ -0,0 +1,105 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import ListContent from '@/components/molecules/ListContent.vue' + +describe('ListContent', () => { + it('renders error when error prop is provided', () => { + const wrapper = mount(ListContent, { + props: { + items: [], + error: 'Something went wrong', + errorTitle: 'Error', + emptyMessage: 'No items', + }, + global: { + stubs: { + ErrorBlock: { + template: '
{{ title }}: {{ error }}
', + props: ['title', 'error'], + }, + }, + }, + }) + + expect(wrapper.html()).toContain('Error') + expect(wrapper.html()).toContain('Something went wrong') + }) + + it('shows loading message when isLoading is true', () => { + const wrapper = mount(ListContent, { + props: { + items: [], + error: null, + errorTitle: 'Error', + emptyMessage: 'No items', + isLoading: true, + }, + global: { + stubs: { + ErrorBlock: true, + }, + }, + }) + + expect(wrapper.text()).toContain('Loading...') + }) + + it('shows empty message when items array is empty', () => { + const wrapper = mount(ListContent, { + props: { + items: [], + error: null, + errorTitle: 'Error', + emptyMessage: 'No items found', + isLoading: false, + }, + global: { + stubs: { + ErrorBlock: true, + }, + }, + }) + + expect(wrapper.text()).toContain('No items found') + }) + + it('renders slot content when items exist', () => { + const wrapper = mount(ListContent, { + props: { + items: [{ id: 1 }, { id: 2 }], + error: null, + errorTitle: 'Error', + emptyMessage: 'No items', + }, + slots: { + item: '
Item content
', + }, + global: { + stubs: { + ErrorBlock: true, + }, + }, + }) + + expect(wrapper.html()).toContain('Item content') + }) + + it('has box class when rendering items', () => { + const wrapper = mount(ListContent, { + props: { + items: [{ id: 1 }], + error: null, + errorTitle: 'Error', + emptyMessage: 'No items', + }, + global: { + stubs: { + ErrorBlock: true, + }, + }, + }) + + const boxDiv = wrapper.find('.box') + expect(boxDiv.exists()).toBe(true) + }) +}) diff --git a/src/components/molecules/ListItem.test.ts b/src/components/molecules/ListItem.test.ts new file mode 100644 index 00000000..500a3bd4 --- /dev/null +++ b/src/components/molecules/ListItem.test.ts @@ -0,0 +1,116 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import ListItem from '@/components/molecules/ListItem.vue' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + RouterLink: { + template: '', + props: ['to'], + }, +})) + +describe('ListItem', () => { + it('renders title as a link', () => { + const wrapper = mount(ListItem, { + props: { + title: 'Test Item', + link: '/test', + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Test Item') + const h3 = wrapper.find('h3') + expect(h3.exists()).toBe(true) + }) + + it('renders subtitle when provided', () => { + const wrapper = mount(ListItem, { + props: { + title: 'Test Item', + subtitle: 'This is a subtitle', + link: '/test', + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + expect(wrapper.text()).toContain('This is a subtitle') + }) + + it('does not render subtitle when not provided', () => { + const wrapper = mount(ListItem, { + props: { + title: 'Test Item', + link: '/test', + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + const p = wrapper.find('p') + expect(p.exists()).toBe(false) + }) + + it('renders slot content', () => { + const wrapper = mount(ListItem, { + props: { + title: 'Test Item', + link: '/test', + }, + slots: { + default: '
Custom content
', + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + expect(wrapper.html()).toContain('Custom content') + }) + + it('has proper styling classes', () => { + const wrapper = mount(ListItem, { + props: { + title: 'Test Item', + link: '/test', + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + const h3 = wrapper.find('h3') + expect(h3.classes()).toContain('text-link') + }) +}) diff --git a/src/components/molecules/ListToolbar.test.ts b/src/components/molecules/ListToolbar.test.ts new file mode 100644 index 00000000..21b6f817 --- /dev/null +++ b/src/components/molecules/ListToolbar.test.ts @@ -0,0 +1,156 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import ListToolbar from '@/components/molecules/ListToolbar.vue' + +// Mock utils/sort +vi.mock('@/utils/sort', () => ({ + DEFAULT_SORT_OPTION: 'created-desc', + SORT_OPTIONS_GROUPED: [ + { + group: 'Fields', + options: [ + { label: 'Created', value: 'created', type: 'field' }, + { label: 'Updated', value: 'updated', type: 'field' }, + ], + }, + { + group: 'Direction', + options: [ + { label: 'Ascending', value: 'asc', type: 'direction' }, + { label: 'Descending', value: 'desc', type: 'direction' }, + ], + }, + ], +})) + +describe('ListToolbar', () => { + it('renders filter input with default placeholder', () => { + const wrapper = mount(ListToolbar, { + props: { + filterQuery: '', + isLoading: false, + contentSection: 'test', + }, + global: { + stubs: { + ActionButton: true, + RefreshIcon: true, + SortDropdown: true, + }, + }, + }) + + const input = wrapper.find('input[type="search"]') + expect(input.exists()).toBe(true) + expect(input.attributes('placeholder')).toBe('Filter by description...') + }) + + it('renders filter input with custom placeholder', () => { + const wrapper = mount(ListToolbar, { + props: { + filterQuery: '', + isLoading: false, + contentSection: 'test', + filterPlaceholder: 'Custom placeholder', + }, + global: { + stubs: { + ActionButton: true, + RefreshIcon: true, + SortDropdown: true, + }, + }, + }) + + const input = wrapper.find('input[type="search"]') + expect(input.attributes('placeholder')).toBe('Custom placeholder') + }) + + it('emits update:filterQuery when input changes', async () => { + const wrapper = mount(ListToolbar, { + props: { + filterQuery: '', + isLoading: false, + contentSection: 'test', + }, + global: { + stubs: { + ActionButton: true, + RefreshIcon: true, + SortDropdown: true, + }, + }, + }) + + const input = wrapper.find('input[type="search"]') + await input.setValue('test query') + + expect(wrapper.emitted('update:filterQuery')).toBeTruthy() + expect(wrapper.emitted('update:filterQuery')?.[0]).toEqual(['test query']) + }) + + it('emits refresh event when refresh button is clicked', async () => { + const wrapper = mount(ListToolbar, { + props: { + filterQuery: '', + isLoading: false, + contentSection: 'test', + }, + global: { + stubs: { + ActionButton: { + template: '', + }, + RefreshIcon: true, + SortDropdown: true, + }, + }, + }) + + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('refresh')).toBeTruthy() + }) + + it('shows "Refreshing..." when isLoading is true', () => { + const wrapper = mount(ListToolbar, { + props: { + filterQuery: '', + isLoading: true, + contentSection: 'test', + }, + global: { + stubs: { + ActionButton: { + template: '', + }, + RefreshIcon: true, + SortDropdown: true, + }, + }, + }) + + expect(wrapper.text()).toContain('Refreshing...') + }) + + it('shows "Refresh" when isLoading is false', () => { + const wrapper = mount(ListToolbar, { + props: { + filterQuery: '', + isLoading: false, + contentSection: 'test', + }, + global: { + stubs: { + ActionButton: { + template: '', + }, + RefreshIcon: true, + SortDropdown: true, + }, + }, + }) + + expect(wrapper.text()).toContain('Refresh') + }) +}) diff --git a/src/components/molecules/NavigationCard.test.ts b/src/components/molecules/NavigationCard.test.ts new file mode 100644 index 00000000..4fa5d9f3 --- /dev/null +++ b/src/components/molecules/NavigationCard.test.ts @@ -0,0 +1,104 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import NavigationCard from '@/components/molecules/NavigationCard.vue' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + RouterLink: { + template: '', + props: ['to'], + }, +})) + +describe('NavigationCard', () => { + it('renders title and description', () => { + const wrapper = mount(NavigationCard, { + props: { + to: '/test', + title: 'Test Title', + description: 'Test Description', + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + ArrowRightIcon: true, + }, + }, + }) + + expect(wrapper.text()).toContain('Test Title') + expect(wrapper.text()).toContain('Test Description') + }) + + it('renders title in h2 tag', () => { + const wrapper = mount(NavigationCard, { + props: { + to: '/test', + title: 'Navigation Title', + description: 'Description', + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + ArrowRightIcon: true, + }, + }, + }) + + const h2 = wrapper.find('h2') + expect(h2.exists()).toBe(true) + expect(h2.text()).toBe('Navigation Title') + }) + + it('renders description in p tag', () => { + const wrapper = mount(NavigationCard, { + props: { + to: '/test', + title: 'Title', + description: 'Card description text', + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + ArrowRightIcon: true, + }, + }, + }) + + const p = wrapper.find('p') + expect(p.exists()).toBe(true) + expect(p.text()).toBe('Card description text') + }) + + it('includes ArrowRightIcon', () => { + const wrapper = mount(NavigationCard, { + props: { + to: '/test', + title: 'Title', + description: 'Description', + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + ArrowRightIcon: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.arrow-icon').exists()).toBe(true) + }) +}) diff --git a/src/components/molecules/NotificationBar.test.ts b/src/components/molecules/NotificationBar.test.ts new file mode 100644 index 00000000..f4c4b2ec --- /dev/null +++ b/src/components/molecules/NotificationBar.test.ts @@ -0,0 +1,94 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import NotificationBar from '@/components/molecules/NotificationBar.vue' + +// Mock cookie utility +vi.mock('@/utils/cookie', () => ({ + Cookie: { + get: vi.fn(), + set: vi.fn(), + }, +})) + +describe('NotificationBar', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders notification when not dismissed', async () => { + const { Cookie } = await import('@/utils/cookie') + vi.mocked(Cookie.get).mockResolvedValue(null) + + const wrapper = mount(NotificationBar, { + global: { + stubs: { + CloseButton: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('This is a general notification message.') + }) + + it('hides notification when previously dismissed', async () => { + const { Cookie } = await import('@/utils/cookie') + vi.mocked(Cookie.get).mockResolvedValue('true') + + const wrapper = mount(NotificationBar, { + global: { + stubs: { + CloseButton: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.find('div').exists()).toBe(false) + }) + + it('hides notification and sets cookie when close button is clicked', async () => { + const { Cookie } = await import('@/utils/cookie') + vi.mocked(Cookie.get).mockResolvedValue(null) + vi.mocked(Cookie.set).mockResolvedValue(undefined) + + const wrapper = mount(NotificationBar, { + global: { + stubs: { + CloseButton: { + template: '', + }, + }, + }, + }) + + await flushPromises() + + expect(wrapper.vm.isVisible).toBe(true) + + await wrapper.findComponent({ name: 'CloseButton' }).trigger('click') + await flushPromises() + + expect(wrapper.vm.isVisible).toBe(false) + expect(Cookie.set).toHaveBeenCalledWith('pmr_notification_dismissed', 'true', 7) + }) + + it('includes warning emoji in notification', async () => { + const { Cookie } = await import('@/utils/cookie') + vi.mocked(Cookie.get).mockResolvedValue(null) + + const wrapper = mount(NotificationBar, { + global: { + stubs: { + CloseButton: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('⚠️') + }) +}) diff --git a/src/components/molecules/PageHeader.test.ts b/src/components/molecules/PageHeader.test.ts new file mode 100644 index 00000000..d7c4eb0a --- /dev/null +++ b/src/components/molecules/PageHeader.test.ts @@ -0,0 +1,86 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import PageHeader from '@/components/molecules/PageHeader.vue' + +describe('PageHeader', () => { + it('renders title in h1 tag', () => { + const wrapper = mount(PageHeader, { + props: { + title: 'Page Title', + }, + }) + + const h1 = wrapper.find('h1') + expect(h1.exists()).toBe(true) + expect(h1.text()).toBe('Page Title') + }) + + it('renders description when provided', () => { + const wrapper = mount(PageHeader, { + props: { + title: 'Page Title', + description: 'This is a page description', + }, + }) + + expect(wrapper.text()).toContain('This is a page description') + }) + + it('does not render description when not provided', () => { + const wrapper = mount(PageHeader, { + props: { + title: 'Page Title', + }, + }) + + const p = wrapper.find('p') + expect(p.exists()).toBe(false) + }) + + it('centers content when centered prop is true', () => { + const wrapper = mount(PageHeader, { + props: { + title: 'Centered Title', + centered: true, + }, + }) + + const div = wrapper.find('div') + expect(div.classes()).toContain('text-center') + }) + + it('does not center content by default', () => { + const wrapper = mount(PageHeader, { + props: { + title: 'Default Title', + }, + }) + + const div = wrapper.find('div') + expect(div.classes()).not.toContain('text-center') + }) + + it('applies proper text size classes to title', () => { + const wrapper = mount(PageHeader, { + props: { + title: 'Title', + }, + }) + + const h1 = wrapper.find('h1') + expect(h1.classes()).toContain('text-4xl') + expect(h1.classes()).toContain('font-bold') + }) + + it('applies proper text size classes to description', () => { + const wrapper = mount(PageHeader, { + props: { + title: 'Title', + description: 'Description text', + }, + }) + + const p = wrapper.find('p') + expect(p.classes()).toContain('text-lg') + }) +}) diff --git a/src/components/molecules/SortDropdown.test.ts b/src/components/molecules/SortDropdown.test.ts new file mode 100644 index 00000000..85a9e031 --- /dev/null +++ b/src/components/molecules/SortDropdown.test.ts @@ -0,0 +1,51 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SortDropdown from '@/components/molecules/SortDropdown.vue' + +describe('SortDropdown', () => { + const mockOptions = [ + { + group: 'Fields', + options: [ + { value: 'description', label: 'Description', type: 'field' as const }, + { value: 'id', label: 'ID', type: 'field' as const }, + { value: 'date', label: 'Date', type: 'field' as const }, + ], + }, + { + group: 'Direction', + options: [ + { value: 'asc', label: 'Ascending', type: 'direction' as const }, + { value: 'desc', label: 'Descending', type: 'direction' as const }, + ], + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders dropdown button with current field label', () => { + const wrapper = mount(SortDropdown, { + props: { + modelValue: 'description-asc', + options: mockOptions, + }, + global: { + stubs: { + ActionButton: { + name: 'ActionButton', + template: '', + props: ['variant', 'size', 'contentSection'], + }, + ArrowUpIcon: true, + ChevronDownIcon: true, + CheckmarkIcon: true, + }, + }, + }) + + expect(wrapper.find('button').exists()).toBe(true) + expect(wrapper.text()).toContain('Description') + }) +}) diff --git a/src/components/molecules/UserDropdown.test.ts b/src/components/molecules/UserDropdown.test.ts new file mode 100644 index 00000000..95065963 --- /dev/null +++ b/src/components/molecules/UserDropdown.test.ts @@ -0,0 +1,132 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import UserDropdown from '@/components/molecules/UserDropdown.vue' +import { useAuthStore } from '@/stores/auth' + +// Mock services +vi.mock('@/services', () => ({ + getAuthService: vi.fn(() => ({ + logout: vi.fn().mockResolvedValue(undefined), + })), +})) + +// Mock Vue Router +const mockPush = vi.fn() +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: mockPush, + }), + RouterLink: { + template: '', + props: ['to'], + }, +})) + +describe('UserDropdown', () => { + let authStore: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + authStore = useAuthStore() + vi.clearAllMocks() + }) + + it('shows login link when not authenticated', () => { + authStore.isAuthenticated = false + + const wrapper = mount(UserDropdown, { + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + UserIcon: true, + }, + }, + }) + + expect(wrapper.text()).toContain('Log in') + }) + + it('shows user icon button when authenticated', () => { + authStore.isAuthenticated = true + + const wrapper = mount(UserDropdown, { + global: { + stubs: { + RouterLink: true, + UserIcon: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.user-icon').exists()).toBe(true) + }) + + it('toggles dropdown when user icon is clicked', async () => { + authStore.isAuthenticated = true + + const wrapper = mount(UserDropdown, { + global: { + stubs: { + RouterLink: true, + UserIcon: true, + }, + }, + }) + + expect((wrapper.vm as any).isOpen).toBe(false) + + await wrapper.find('button[aria-label="User menu"]').trigger('click') + + expect((wrapper.vm as any).isOpen).toBe(true) + }) + + it('shows logout button when dropdown is open', async () => { + authStore.isAuthenticated = true + + const wrapper = mount(UserDropdown, { + global: { + stubs: { + RouterLink: true, + UserIcon: true, + }, + }, + }) + + await wrapper.find('button[aria-label="User menu"]').trigger('click') + + expect(wrapper.text()).toContain('Log out') + }) + + it('calls logout and redirects to home on logout click', async () => { + const { getAuthService } = await import('@/services') + const mockAuthService = vi.mocked(getAuthService)() + + authStore.isAuthenticated = true + + const wrapper = mount(UserDropdown, { + global: { + stubs: { + RouterLink: true, + UserIcon: true, + }, + }, + }) + + await wrapper.find('button[aria-label="User menu"]').trigger('click') + + const logoutButtons = wrapper.findAll('button') + const logoutButton = logoutButtons.find(btn => btn.text() === 'Log out') + await logoutButton?.trigger('click') + + await flushPromises() + + expect(mockAuthService.logout).toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/') + }) +}) diff --git a/src/components/organisms/ExposureFileDetail.test.ts b/src/components/organisms/ExposureFileDetail.test.ts new file mode 100644 index 00000000..99a92ce4 --- /dev/null +++ b/src/components/organisms/ExposureFileDetail.test.ts @@ -0,0 +1,105 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ExposureFileDetail from '@/components/organisms/ExposureFileDetail.vue' +import { useExposureStore } from '@/stores/exposure' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + useRoute: () => ({ + path: '/exposures/test/file', + }), + useRouter: () => ({ + push: vi.fn(), + back: vi.fn(), + }), +})) + +// Mock composables +vi.mock('@/composables/useBackNavigation', () => ({ + useBackNavigation: vi.fn(() => ({ + goBack: vi.fn(), + })), +})) + +describe('ExposureFileDetail', () => { + let exposureStore: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + exposureStore = useExposureStore() + vi.clearAllMocks() + }) + + it('renders loading state initially', () => { + vi.spyOn(exposureStore, 'getExposureFileInfo').mockImplementation( + () => new Promise(() => {}), + ) + + const wrapper = mount(ExposureFileDetail, { + props: { + alias: 'test-alias', + file: 'test.cellml', + }, + global: { + stubs: { + BackButton: true, + LoadingBox: { + template: '
Loading...
', + }, + ErrorBlock: true, + PageHeader: true, + }, + }, + }) + + expect(wrapper.find('.loading').exists()).toBe(true) + }) + + it('renders error when file fails to load', async () => { + vi.spyOn(exposureStore, 'getExposureFileInfo').mockRejectedValue(new Error('File not found')) + + const wrapper = mount(ExposureFileDetail, { + props: { + alias: 'test-alias', + file: 'test.cellml', + }, + global: { + stubs: { + BackButton: true, + LoadingBox: true, + ErrorBlock: { + template: '
{{ error }}
', + props: ['title', 'error'], + }, + PageHeader: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.find('.error').exists()).toBe(true) + }) + + it('renders BackButton component', () => { + const wrapper = mount(ExposureFileDetail, { + props: { + alias: 'test-alias', + file: 'test.cellml', + }, + global: { + stubs: { + BackButton: { + template: '
Back
', + }, + LoadingBox: true, + ErrorBlock: true, + PageHeader: true, + }, + }, + }) + + expect(wrapper.find('.back-button').exists()).toBe(true) + }) +}) diff --git a/src/components/organisms/Exposures.test.ts b/src/components/organisms/Exposures.test.ts new file mode 100644 index 00000000..427833d8 --- /dev/null +++ b/src/components/organisms/Exposures.test.ts @@ -0,0 +1,106 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Exposures from '@/components/organisms/Exposures.vue' +import { useExposureStore } from '@/stores/exposure' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + useRoute: () => ({ + query: {}, + }), + useRouter: () => ({ + replace: vi.fn(), + }), +})) + +// Mock utils +vi.mock('@/utils/sort', () => ({ + DEFAULT_SORT_OPTION: 'created-desc', + sortEntities: vi.fn((items) => items), +})) + +vi.mock('@/utils/format', () => ({ + formatDate: vi.fn((date) => new Date(date * 1000).toLocaleDateString()), +})) + +describe('Exposures', () => { + let exposureStore: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + exposureStore = useExposureStore() + vi.clearAllMocks() + }) + + it('renders ListToolbar component', () => { + vi.spyOn(exposureStore, 'fetchExposures').mockResolvedValue() + + const wrapper = mount(Exposures, { + global: { + stubs: { + ListToolbar: { + template: '
Toolbar
', + }, + ListContainer: true, + ListItem: true, + }, + }, + }) + + expect(wrapper.find('.toolbar').exists()).toBe(true) + }) + + it('renders ListContainer component', () => { + vi.spyOn(exposureStore, 'fetchExposures').mockResolvedValue() + + const wrapper = mount(Exposures, { + global: { + stubs: { + ListToolbar: true, + ListContainer: { + template: '
', + }, + ListItem: true, + }, + }, + }) + + expect(wrapper.find('.container').exists()).toBe(true) + }) + + it('calls fetchExposures on mount', () => { + const fetchSpy = vi.spyOn(exposureStore, 'fetchExposures').mockResolvedValue() + + mount(Exposures, { + global: { + stubs: { + ListToolbar: true, + ListContainer: true, + ListItem: true, + }, + }, + }) + + expect(fetchSpy).toHaveBeenCalled() + }) + + it('handles filter query changes', async () => { + vi.spyOn(exposureStore, 'fetchExposures').mockResolvedValue() + + const wrapper = mount(Exposures, { + global: { + stubs: { + ListToolbar: true, + ListContainer: true, + ListItem: true, + }, + }, + }) + + wrapper.vm.filterQuery = 'test query' + await wrapper.vm.$nextTick() + + expect(wrapper.vm.filterQuery).toBe('test query') + }) +}) diff --git a/src/components/organisms/Footer.test.ts b/src/components/organisms/Footer.test.ts new file mode 100644 index 00000000..946ebcb1 --- /dev/null +++ b/src/components/organisms/Footer.test.ts @@ -0,0 +1,77 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import Footer from '@/components/organisms/Footer.vue' + +describe('Footer', () => { + it('renders copyright text with current year', () => { + const wrapper = mount(Footer, { + global: { + stubs: { + GitHubIcon: true, + }, + }, + }) + + const currentYear = new Date().getFullYear() + expect(wrapper.text()).toContain(`Copyright 2025-${currentYear} IUPS Physiome project`) + }) + + it('renders GitHub repository links', () => { + const wrapper = mount(Footer, { + global: { + stubs: { + GitHubIcon: true, + }, + }, + }) + + const links = wrapper.findAll('a') + expect(links.length).toBeGreaterThanOrEqual(2) + expect(wrapper.text()).toContain('Platform') + expect(wrapper.text()).toContain('Web App') + }) + + it('links open in new tab with proper security attributes', () => { + const wrapper = mount(Footer, { + global: { + stubs: { + GitHubIcon: true, + }, + }, + }) + + const links = wrapper.findAll('a') + links.forEach(link => { + expect(link.attributes('target')).toBe('_blank') + expect(link.attributes('rel')).toBe('noopener noreferrer') + }) + }) + + it('has correct GitHub URLs', () => { + const wrapper = mount(Footer, { + global: { + stubs: { + GitHubIcon: true, + }, + }, + }) + + const html = wrapper.html() + expect(html).toContain('https://github.com/Physiome/pmrplatform') + expect(html).toContain('https://github.com/Physiome/pmrapp-frontend') + }) + + it('has footer tag with appropriate styling', () => { + const wrapper = mount(Footer, { + global: { + stubs: { + GitHubIcon: true, + }, + }, + }) + + const footer = wrapper.find('footer') + expect(footer.exists()).toBe(true) + expect(footer.classes()).toContain('bg-gray-800') + }) +}) diff --git a/src/components/organisms/Header.test.ts b/src/components/organisms/Header.test.ts new file mode 100644 index 00000000..02b2d36e --- /dev/null +++ b/src/components/organisms/Header.test.ts @@ -0,0 +1,98 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import Header from '@/components/organisms/Header.vue' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + useRoute: () => ({ + path: '/workspaces', + }), + RouterLink: { + template: '', + props: ['to'], + }, +})) + +describe('Header', () => { + it('renders logo link to home', () => { + const wrapper = mount(Header, { + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + UserDropdown: true, + }, + }, + }) + + const img = wrapper.find('img') + expect(img.exists()).toBe(true) + expect(img.attributes('alt')).toBe('Physiome Model Repository') + }) + + it('renders navigation links for Workspaces and Exposures', () => { + const wrapper = mount(Header, { + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + UserDropdown: true, + }, + }, + }) + + expect(wrapper.text()).toContain('Workspaces') + expect(wrapper.text()).toContain('Exposures') + }) + + it('renders UserDropdown component', () => { + const wrapper = mount(Header, { + global: { + stubs: { + RouterLink: true, + UserDropdown: { + template: '
User Dropdown
', + }, + }, + }, + }) + + expect(wrapper.find('.user-dropdown-stub').exists()).toBe(true) + }) + + it('has sticky header with proper classes', () => { + const wrapper = mount(Header, { + global: { + stubs: { + RouterLink: true, + UserDropdown: true, + }, + }, + }) + + const header = wrapper.find('header') + expect(header.exists()).toBe(true) + expect(header.classes()).toContain('sticky') + }) + + it('renders nav element with list items', () => { + const wrapper = mount(Header, { + global: { + stubs: { + RouterLink: true, + UserDropdown: true, + }, + }, + }) + + const nav = wrapper.find('nav') + expect(nav.exists()).toBe(true) + + const ul = wrapper.find('ul') + expect(ul.exists()).toBe(true) + }) +}) diff --git a/src/components/organisms/Home.test.ts b/src/components/organisms/Home.test.ts new file mode 100644 index 00000000..1120a67c --- /dev/null +++ b/src/components/organisms/Home.test.ts @@ -0,0 +1,96 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import Home from '@/components/organisms/Home.vue' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + RouterLink: { + template: '', + props: ['to'], + }, +})) + +describe('Home', () => { + it('renders main title', () => { + const wrapper = mount(Home, { + global: { + stubs: { + NavigationCard: true, + }, + }, + }) + + expect(wrapper.text()).toContain('Physiome Model Repository') + }) + + it('renders description text', () => { + const wrapper = mount(Home, { + global: { + stubs: { + NavigationCard: true, + }, + }, + }) + + expect(wrapper.text()).toContain('public resource for storing') + expect(wrapper.text()).toContain('CellML models') + }) + + it('renders NavigationCard for Workspaces', () => { + const wrapper = mount(Home, { + global: { + stubs: { + NavigationCard: { + template: '', + props: ['to', 'title', 'description'], + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Workspaces') + }) + + it('renders NavigationCard for Exposures', () => { + const wrapper = mount(Home, { + global: { + stubs: { + NavigationCard: { + template: '', + props: ['to', 'title', 'description'], + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Exposures') + }) + + it('has proper heading hierarchy', () => { + const wrapper = mount(Home, { + global: { + stubs: { + NavigationCard: true, + }, + }, + }) + + const h1 = wrapper.find('h1') + expect(h1.exists()).toBe(true) + expect(h1.text()).toBe('Physiome Model Repository') + }) + + it('displays description paragraph', () => { + const wrapper = mount(Home, { + global: { + stubs: { + NavigationCard: true, + }, + }, + }) + + const p = wrapper.find('p') + expect(p.exists()).toBe(true) + expect(p.classes()).toContain('text-xl') + }) +}) diff --git a/src/components/organisms/Login.test.ts b/src/components/organisms/Login.test.ts new file mode 100644 index 00000000..4c65c827 --- /dev/null +++ b/src/components/organisms/Login.test.ts @@ -0,0 +1,163 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Login from '@/components/organisms/Login.vue' +import { useAuthStore } from '@/stores/auth' + +// Mock services +const mockLogin = vi.fn() +vi.mock('@/services', () => ({ + getAuthService: vi.fn(() => ({ + login: mockLogin, + })), +})) + +// Mock Vue Router +const mockPush = vi.fn() +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +describe('Login', () => { + let authStore: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + authStore = useAuthStore() + vi.clearAllMocks() + }) + + it('renders login form with username and password fields', () => { + const wrapper = mount(Login, { + global: { + stubs: { + ActionButton: { + template: '', + }, + }, + }, + }) + + const inputs = wrapper.findAll('input') + expect(inputs.length).toBeGreaterThanOrEqual(2) + }) + + it('shows error when submitting empty form', async () => { + const wrapper = mount(Login, { + global: { + stubs: { + ActionButton: { + template: '', + }, + }, + }, + }) + + const form = wrapper.find('form') + await form.trigger('submit.prevent') + await flushPromises() + + expect(wrapper.vm.error).toBeTruthy() + }) + + it('calls login service with username and password', async () => { + mockLogin.mockResolvedValue('mock-token') + + const wrapper = mount(Login, { + global: { + stubs: { + ActionButton: { + template: '', + }, + }, + }, + }) + + wrapper.vm.username = 'testuser' + wrapper.vm.password = 'testpass' + + const form = wrapper.find('form') + await form.trigger('submit.prevent') + await flushPromises() + + expect(mockLogin).toHaveBeenCalledWith({ + login: 'testuser', + password: 'testpass', + }) + }) + + it('redirects to home on successful login', async () => { + mockLogin.mockResolvedValue('mock-token') + + const wrapper = mount(Login, { + global: { + stubs: { + ActionButton: { + template: '', + }, + }, + }, + }) + + wrapper.vm.username = 'testuser' + wrapper.vm.password = 'testpass' + + const form = wrapper.find('form') + await form.trigger('submit.prevent') + await flushPromises() + + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('shows error message on login failure', async () => { + mockLogin.mockRejectedValue(new Error('Invalid credentials')) + + const wrapper = mount(Login, { + global: { + stubs: { + ActionButton: { + template: '', + }, + }, + }, + }) + + wrapper.vm.username = 'testuser' + wrapper.vm.password = 'wrongpass' + + const form = wrapper.find('form') + await form.trigger('submit.prevent') + await flushPromises() + + expect(wrapper.vm.error).toBe('Invalid credentials') + }) + + it('sets loading state during login', async () => { + mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve('token'), 100))) + + const wrapper = mount(Login, { + global: { + stubs: { + ActionButton: { + template: '', + }, + }, + }, + }) + + wrapper.vm.username = 'testuser' + wrapper.vm.password = 'testpass' + + const form = wrapper.find('form') + const submitPromise = form.trigger('submit.prevent') + + expect(wrapper.vm.isLoading).toBe(true) + + await submitPromise + await flushPromises() + + expect(wrapper.vm.isLoading).toBe(false) + }) +}) diff --git a/src/components/organisms/WorkspaceDetail.test.ts b/src/components/organisms/WorkspaceDetail.test.ts new file mode 100644 index 00000000..4e3c7e61 --- /dev/null +++ b/src/components/organisms/WorkspaceDetail.test.ts @@ -0,0 +1,145 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WorkspaceDetail from '@/components/organisms/WorkspaceDetail.vue' +import { useWorkspaceStore } from '@/stores/workspace' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + }), +})) + +// Mock services +vi.mock('@/services/downloadUrlService', () => ({ + getArchiveDownloadUrls: vi.fn(), +})) + +// Mock utils +vi.mock('@/utils/download', () => ({ + downloadWorkspaceFile: vi.fn(), +})) + +vi.mock('@/utils/format', () => ({ + formatFileCount: vi.fn((count) => `${count} items`), +})) + +describe('WorkspaceDetail', () => { + let workspaceStore: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + workspaceStore = useWorkspaceStore() + vi.clearAllMocks() + }) + + it('renders loading state initially', () => { + vi.spyOn(workspaceStore, 'getWorkspaceInfo').mockImplementation( + () => new Promise(() => {}), + ) + + const wrapper = mount(WorkspaceDetail, { + props: { + alias: 'test-alias', + }, + global: { + stubs: { + BackButton: true, + LoadingBox: { + template: '
Loading...
', + }, + ErrorBlock: true, + PageHeader: true, + ActionButton: true, + FileIcon: true, + FolderIcon: true, + DownloadIcon: true, + }, + }, + }) + + expect(wrapper.find('.loading').exists()).toBe(true) + }) + + it('renders error when workspace fails to load', async () => { + vi.spyOn(workspaceStore, 'getWorkspaceInfo').mockRejectedValue(new Error('Workspace not found')) + + const wrapper = mount(WorkspaceDetail, { + props: { + alias: 'test-alias', + }, + global: { + stubs: { + BackButton: true, + LoadingBox: true, + ErrorBlock: { + template: '
{{ error }}
', + props: ['title', 'error'], + }, + PageHeader: true, + ActionButton: true, + FileIcon: true, + FolderIcon: true, + DownloadIcon: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.find('.error').exists()).toBe(true) + }) + + it('renders BackButton component', () => { + const wrapper = mount(WorkspaceDetail, { + props: { + alias: 'test-alias', + }, + global: { + stubs: { + BackButton: { + template: '
Back
', + }, + LoadingBox: true, + ErrorBlock: true, + PageHeader: true, + ActionButton: true, + FileIcon: true, + FolderIcon: true, + DownloadIcon: true, + }, + }, + }) + + expect(wrapper.find('.back-button').exists()).toBe(true) + }) + + it('component loads with required props', () => { + vi.spyOn(workspaceStore, 'getWorkspaceInfo').mockImplementation( + () => new Promise(() => {}), + ) + + const wrapper = mount(WorkspaceDetail, { + props: { + alias: 'test-workspace', + }, + global: { + stubs: { + BackButton: true, + LoadingBox: true, + ErrorBlock: true, + PageHeader: true, + ActionButton: true, + FileIcon: true, + FolderIcon: true, + DownloadIcon: true, + }, + }, + }) + + expect(wrapper.exists()).toBe(true) + expect(wrapper.vm.$props.alias).toBe('test-workspace') + }) +}) diff --git a/src/components/organisms/WorkspaceFileDetail.test.ts b/src/components/organisms/WorkspaceFileDetail.test.ts new file mode 100644 index 00000000..5a0ffc4e --- /dev/null +++ b/src/components/organisms/WorkspaceFileDetail.test.ts @@ -0,0 +1,202 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WorkspaceFileDetail from '@/components/organisms/WorkspaceFileDetail.vue' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + useRoute: () => ({ + path: '/workspaces/test/file', + }), + useRouter: () => ({ + push: vi.fn(), + back: vi.fn(), + }), +})) + +// Mock composables +vi.mock('@/composables/useBackNavigation', () => ({ + useBackNavigation: vi.fn(() => ({ + goBack: vi.fn(), + })), +})) + +// Mock services +vi.mock('@/services', () => ({ + getWorkspaceService: vi.fn(() => ({ + getRawFile: vi.fn(), + getRawFileBlob: vi.fn(), + })), +})) + +// Mock utils +vi.mock('@/utils/download', () => ({ + downloadFileFromContent: vi.fn(), + downloadFileFromBlob: vi.fn(), +})) + +vi.mock('@/utils/file', () => ({ + isCodeFile: vi.fn(), + isImageFile: vi.fn(), + isMarkdownFile: vi.fn(), + isPdfFile: vi.fn(), + isSvgFile: vi.fn(), +})) + +vi.mock('@/utils/markdown', () => ({ + renderMarkdown: vi.fn((text) => text), +})) + +describe('WorkspaceFileDetail', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading state initially', () => { + const { getWorkspaceService } = require('@/services') + vi.mocked(getWorkspaceService)().getRawFile = vi.fn(() => new Promise(() => {})) + + const wrapper = mount(WorkspaceFileDetail, { + props: { + alias: 'test-alias', + commitId: 'abc123', + path: 'test.txt', + }, + global: { + stubs: { + BackButton: true, + LoadingBox: { + template: '
Loading...
', + }, + ErrorBlock: true, + PageHeader: true, + ActionButton: true, + CodeBlock: true, + CodeIcon: true, + DownloadIcon: true, + PreviewIcon: true, + }, + }, + }) + + expect(wrapper.find('.loading').exists()).toBe(true) + }) + + it('renders error when file fails to load', async () => { + const { getWorkspaceService } = require('@/services') + vi.mocked(getWorkspaceService)().getRawFile = vi.fn().mockRejectedValue(new Error('File not found')) + + const wrapper = mount(WorkspaceFileDetail, { + props: { + alias: 'test-alias', + commitId: 'abc123', + path: 'test.txt', + }, + global: { + stubs: { + BackButton: true, + LoadingBox: true, + ErrorBlock: { + template: '
{{ error }}
', + props: ['title', 'error'], + }, + PageHeader: true, + ActionButton: true, + CodeBlock: true, + CodeIcon: true, + DownloadIcon: true, + PreviewIcon: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.find('.error').exists()).toBe(true) + }) + + it('renders BackButton component', () => { + const wrapper = mount(WorkspaceFileDetail, { + props: { + alias: 'test-alias', + commitId: 'abc123', + path: 'test.txt', + }, + global: { + stubs: { + BackButton: { + template: '
Back
', + }, + LoadingBox: true, + ErrorBlock: true, + PageHeader: true, + ActionButton: true, + CodeBlock: true, + CodeIcon: true, + DownloadIcon: true, + PreviewIcon: true, + }, + }, + }) + + expect(wrapper.find('.back-button').exists()).toBe(true) + }) + + it('component loads with required props', () => { + const { getWorkspaceService } = require('@/services') + vi.mocked(getWorkspaceService)().getRawFile = vi.fn(() => new Promise(() => {})) + + const wrapper = mount(WorkspaceFileDetail, { + props: { + alias: 'test-workspace', + commitId: 'commit-hash', + path: 'folder/file.txt', + }, + global: { + stubs: { + BackButton: true, + LoadingBox: true, + ErrorBlock: true, + PageHeader: true, + ActionButton: true, + CodeBlock: true, + CodeIcon: true, + DownloadIcon: true, + PreviewIcon: true, + }, + }, + }) + + expect(wrapper.exists()).toBe(true) + expect(wrapper.vm.$props.alias).toBe('test-workspace') + expect(wrapper.vm.$props.commitId).toBe('commit-hash') + expect(wrapper.vm.$props.path).toBe('folder/file.txt') + }) + + it('extracts filename from path correctly', () => { + const { getWorkspaceService } = require('@/services') + vi.mocked(getWorkspaceService)().getRawFile = vi.fn(() => new Promise(() => {})) + + const wrapper = mount(WorkspaceFileDetail, { + props: { + alias: 'test-alias', + commitId: 'abc123', + path: 'folder/subfolder/test.txt', + }, + global: { + stubs: { + BackButton: true, + LoadingBox: true, + ErrorBlock: true, + PageHeader: true, + ActionButton: true, + CodeBlock: true, + CodeIcon: true, + DownloadIcon: true, + PreviewIcon: true, + }, + }, + }) + + expect(wrapper.vm.filename).toBe('test.txt') + }) +}) diff --git a/src/components/organisms/Workspaces.test.ts b/src/components/organisms/Workspaces.test.ts new file mode 100644 index 00000000..23e35b8d --- /dev/null +++ b/src/components/organisms/Workspaces.test.ts @@ -0,0 +1,106 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Workspaces from '@/components/organisms/Workspaces.vue' +import { useWorkspaceStore } from '@/stores/workspace' + +// Mock Vue Router +vi.mock('vue-router', () => ({ + useRoute: () => ({ + query: {}, + }), + useRouter: () => ({ + replace: vi.fn(), + }), +})) + +// Mock utils +vi.mock('@/utils/sort', () => ({ + DEFAULT_SORT_OPTION: 'created-desc', + sortEntities: vi.fn((items) => items), +})) + +vi.mock('@/utils/format', () => ({ + formatDate: vi.fn((date) => new Date(date * 1000).toLocaleDateString()), +})) + +describe('Workspaces', () => { + let workspaceStore: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + workspaceStore = useWorkspaceStore() + vi.clearAllMocks() + }) + + it('renders ListToolbar component', () => { + vi.spyOn(workspaceStore, 'fetchWorkspaces').mockResolvedValue() + + const wrapper = mount(Workspaces, { + global: { + stubs: { + ListToolbar: { + template: '
Toolbar
', + }, + ListContainer: true, + ListItem: true, + }, + }, + }) + + expect(wrapper.find('.toolbar').exists()).toBe(true) + }) + + it('renders ListContainer component', () => { + vi.spyOn(workspaceStore, 'fetchWorkspaces').mockResolvedValue() + + const wrapper = mount(Workspaces, { + global: { + stubs: { + ListToolbar: true, + ListContainer: { + template: '
', + }, + ListItem: true, + }, + }, + }) + + expect(wrapper.find('.container').exists()).toBe(true) + }) + + it('calls fetchWorkspaces on mount', () => { + const fetchSpy = vi.spyOn(workspaceStore, 'fetchWorkspaces').mockResolvedValue() + + mount(Workspaces, { + global: { + stubs: { + ListToolbar: true, + ListContainer: true, + ListItem: true, + }, + }, + }) + + expect(fetchSpy).toHaveBeenCalled() + }) + + it('handles filter query changes', async () => { + vi.spyOn(workspaceStore, 'fetchWorkspaces').mockResolvedValue() + + const wrapper = mount(Workspaces, { + global: { + stubs: { + ListToolbar: true, + ListContainer: true, + ListItem: true, + }, + }, + }) + + wrapper.vm.filterQuery = 'test query' + await wrapper.vm.$nextTick() + + expect(wrapper.vm.filterQuery).toBe('test query') + }) +}) diff --git a/src/utils/analytics.test.ts b/src/utils/analytics.test.ts new file mode 100644 index 00000000..c40d5238 --- /dev/null +++ b/src/utils/analytics.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { trackButtonClick } from '@/utils/analytics' + +// Mock vue-gtag +vi.mock('vue-gtag', () => ({ + event: vi.fn(), +})) + +describe('analytics', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls event with correct parameters', async () => { + const { event } = await import('vue-gtag') + + trackButtonClick({ + button_name: 'Test Button', + content_section: 'Test Section', + link_category: '/test', + }) + + expect(event).toHaveBeenCalledWith('button_click', { + button_name: 'Test Button', + content_section: 'Test Section', + link_category: '/test', + }) + }) + + it('tracks button click with all payload fields', async () => { + const { event } = await import('vue-gtag') + + const payload = { + button_name: 'Download', + content_section: 'File Detail', + link_category: '/files/123', + } + + trackButtonClick(payload) + + expect(event).toHaveBeenCalledTimes(1) + expect(event).toHaveBeenCalledWith('button_click', payload) + }) +}) diff --git a/src/utils/cookie.test.ts b/src/utils/cookie.test.ts new file mode 100644 index 00000000..1bccdab6 --- /dev/null +++ b/src/utils/cookie.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Cookie } from '@/utils/cookie' + +// Mock cookieStore +const mockCookieStore = { + get: vi.fn(), + set: vi.fn(), +} + +global.cookieStore = mockCookieStore as any + +describe('cookie', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Cookie.get', () => { + it('returns cookie value when cookie exists', async () => { + mockCookieStore.get.mockResolvedValue({ value: 'test-value' }) + + const result = await Cookie.get('test-cookie') + + expect(result).toBe('test-value') + expect(mockCookieStore.get).toHaveBeenCalledWith('test-cookie') + }) + + it('returns null when cookie does not exist', async () => { + mockCookieStore.get.mockResolvedValue(null) + + const result = await Cookie.get('nonexistent-cookie') + + expect(result).toBeNull() + }) + + it('returns null when cookie value is undefined', async () => { + mockCookieStore.get.mockResolvedValue({ value: undefined }) + + const result = await Cookie.get('test-cookie') + + expect(result).toBeNull() + }) + }) + + describe('Cookie.set', () => { + it('sets cookie with correct parameters', async () => { + const mockDate = new Date('2024-01-01T00:00:00.000Z') + vi.setSystemTime(mockDate) + + await Cookie.set('test-cookie', 'test-value', 7) + + const expectedExpires = new Date(mockDate.getTime() + 7 * 24 * 60 * 60 * 1000) + + expect(mockCookieStore.set).toHaveBeenCalledWith({ + name: 'test-cookie', + value: 'test-value', + expires: expectedExpires.getTime(), + path: '/', + }) + + vi.useRealTimers() + }) + + it('calculates expiry correctly for different day counts', async () => { + const mockDate = new Date('2024-01-01T00:00:00.000Z') + vi.setSystemTime(mockDate) + + await Cookie.set('test-cookie', 'value', 30) + + const expectedExpires = new Date(mockDate.getTime() + 30 * 24 * 60 * 60 * 1000) + + expect(mockCookieStore.set).toHaveBeenCalledWith({ + name: 'test-cookie', + value: 'value', + expires: expectedExpires.getTime(), + path: '/', + }) + + vi.useRealTimers() + }) + }) +}) diff --git a/src/utils/download.test.ts b/src/utils/download.test.ts new file mode 100644 index 00000000..0f7153a3 --- /dev/null +++ b/src/utils/download.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { downloadFileFromBlob, downloadFileFromContent, downloadWorkspaceFile } from '@/utils/download' + +// Create stable mock functions. +const mockGetRawFile = vi.fn() +const mockGetRawFileBlob = vi.fn() + +// Mock services. +vi.mock('@/services', () => ({ + getWorkspaceService: vi.fn(() => ({ + getRawFile: mockGetRawFile, + getRawFileBlob: mockGetRawFileBlob, + })), +})) + +// Mock file utility. +vi.mock('./file', () => ({ + isBinaryFile: vi.fn(), +})) + +describe('download', () => { + let mockCreateElement: any + let mockAppendChild: any + let mockRemoveChild: any + let mockClick: any + let mockCreateObjectURL: any + let mockRevokeObjectURL: any + + beforeEach(() => { + mockClick = vi.fn() + mockAppendChild = vi.fn() + mockRemoveChild = vi.fn() + mockCreateObjectURL = vi.fn(() => 'blob:mock-url') + mockRevokeObjectURL = vi.fn() + + mockCreateElement = vi.spyOn(document, 'createElement').mockReturnValue({ + click: mockClick, + href: '', + download: '', + } as any) + + vi.spyOn(document.body, 'appendChild').mockImplementation(mockAppendChild) + vi.spyOn(document.body, 'removeChild').mockImplementation(mockRemoveChild) + + globalThis.URL.createObjectURL = mockCreateObjectURL + globalThis.URL.revokeObjectURL = mockRevokeObjectURL + + vi.clearAllMocks() + }) + + describe('downloadFileFromContent', () => { + it('creates blob and triggers download', () => { + const content = 'test file content' + const filename = 'test.txt' + + downloadFileFromContent(content, filename) + + expect(mockCreateElement).toHaveBeenCalledWith('a') + expect(mockClick).toHaveBeenCalled() + expect(mockAppendChild).toHaveBeenCalled() + expect(mockRemoveChild).toHaveBeenCalled() + expect(mockRevokeObjectURL).toHaveBeenCalled() + }) + }) + + describe('downloadFileFromBlob', () => { + it('creates download link from blob', () => { + const blob = new Blob(['test'], { type: 'text/plain' }) + const filename = 'test.txt' + + downloadFileFromBlob(blob, filename) + + expect(mockCreateObjectURL).toHaveBeenCalledWith(blob) + expect(mockClick).toHaveBeenCalled() + expect(mockRevokeObjectURL).toHaveBeenCalled() + }) + }) + + describe('downloadWorkspaceFile', () => { + it('downloads binary file as blob', async () => { + const { isBinaryFile } = await import('./file') + + const mockBlob = new Blob(['binary content']) + vi.mocked(isBinaryFile).mockReturnValue(true) + mockGetRawFileBlob.mockResolvedValue(mockBlob) + + await downloadWorkspaceFile('test-alias', 'commit123', 'image.png') + + expect(isBinaryFile).toHaveBeenCalledWith('image.png') + expect(mockGetRawFileBlob).toHaveBeenCalledWith('test-alias', 'commit123', 'image.png') + expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob) + }) + + it('downloads text file as content', async () => { + const { isBinaryFile } = await import('./file') + + vi.mocked(isBinaryFile).mockReturnValue(false) + mockGetRawFile.mockResolvedValue('text content') + + await downloadWorkspaceFile('test-alias', 'commit123', 'file.txt') + + expect(isBinaryFile).toHaveBeenCalledWith('file.txt') + expect(mockGetRawFile).toHaveBeenCalledWith('test-alias', 'commit123', 'file.txt') + }) + + it('handles download errors', async () => { + const { isBinaryFile } = await import('./file') + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(isBinaryFile).mockReturnValue(false) + mockGetRawFile.mockRejectedValue(new Error('Download failed')) + + await expect(downloadWorkspaceFile('test-alias', 'commit123', 'file.txt')).rejects.toThrow('Download failed') + + expect(consoleErrorSpy).toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts new file mode 100644 index 00000000..ad930481 --- /dev/null +++ b/src/utils/file.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { getFileExtension, isBinaryFile, isCodeFile, isImageFile, isMarkdownFile, isPdfFile, isSvgFile } from '@/utils/file' + +describe('file', () => { + describe('getFileExtension', () => { + it('returns extension for normal files', () => { + expect(getFileExtension('test.txt')).toBe('txt') + expect(getFileExtension('document.pdf')).toBe('pdf') + expect(getFileExtension('image.png')).toBe('png') + }) + + it('returns extension in lowercase', () => { + expect(getFileExtension('test.TXT')).toBe('txt') + expect(getFileExtension('IMAGE.PNG')).toBe('png') + }) + + it('handles multiple dots in filename', () => { + expect(getFileExtension('test.file.txt')).toBe('txt') + expect(getFileExtension('archive.tar.gz')).toBe('gz') + }) + + it('handles dotfiles', () => { + expect(getFileExtension('.gitignore')).toBe('gitignore') + expect(getFileExtension('.editorconfig')).toBe('editorconfig') + }) + + it('returns empty string for files without extension', () => { + expect(getFileExtension('README')).toBe('') + expect(getFileExtension('Makefile')).toBe('') + }) + + it('returns empty string when dot is at the end', () => { + expect(getFileExtension('test.')).toBe('') + }) + }) + + describe('isImageFile', () => { + it('returns true for image extensions', () => { + expect(isImageFile('photo.png')).toBe(true) + expect(isImageFile('image.jpg')).toBe(true) + expect(isImageFile('picture.jpeg')).toBe(true) + expect(isImageFile('animated.gif')).toBe(true) + expect(isImageFile('bitmap.bmp')).toBe(true) + expect(isImageFile('modern.webp')).toBe(true) + }) + + it('returns false for non-image files', () => { + expect(isImageFile('document.pdf')).toBe(false) + expect(isImageFile('text.txt')).toBe(false) + expect(isImageFile('image.svg')).toBe(false) + }) + }) + + describe('isSvgFile', () => { + it('returns true for SVG files', () => { + expect(isSvgFile('icon.svg')).toBe(true) + expect(isSvgFile('IMAGE.SVG')).toBe(true) + }) + + it('returns false for non-SVG files', () => { + expect(isSvgFile('image.png')).toBe(false) + expect(isSvgFile('document.pdf')).toBe(false) + }) + }) + + describe('isPdfFile', () => { + it('returns true for PDF files', () => { + expect(isPdfFile('document.pdf')).toBe(true) + expect(isPdfFile('REPORT.PDF')).toBe(true) + }) + + it('returns false for non-PDF files', () => { + expect(isPdfFile('document.doc')).toBe(false) + expect(isPdfFile('image.png')).toBe(false) + }) + }) + + describe('isMarkdownFile', () => { + it('returns true for markdown files', () => { + expect(isMarkdownFile('README.md')).toBe(true) + expect(isMarkdownFile('document.markdown')).toBe(true) + expect(isMarkdownFile('NOTES.MD')).toBe(true) + }) + + it('returns false for non-markdown files', () => { + expect(isMarkdownFile('document.txt')).toBe(false) + expect(isMarkdownFile('code.js')).toBe(false) + }) + }) + + describe('isCodeFile', () => { + it('returns true for common code file extensions', () => { + expect(isCodeFile('script.py')).toBe(true) + expect(isCodeFile('app.js')).toBe(true) + expect(isCodeFile('component.ts')).toBe(true) + expect(isCodeFile('style.css')).toBe(true) + expect(isCodeFile('index.html')).toBe(true) + expect(isCodeFile('config.json')).toBe(true) + expect(isCodeFile('data.xml')).toBe(true) + expect(isCodeFile('settings.yaml')).toBe(true) + }) + + it('returns true for specialized file extensions', () => { + expect(isCodeFile('model.cellml')).toBe(true) + expect(isCodeFile('simulation.sedml')).toBe(true) + }) + + it('returns true for dotfiles', () => { + expect(isCodeFile('.gitignore')).toBe(true) + expect(isCodeFile('.editorconfig')).toBe(true) + }) + + it('returns false for non-code files', () => { + expect(isCodeFile('image.png')).toBe(false) + expect(isCodeFile('document.pdf')).toBe(false) + expect(isCodeFile('video.mp4')).toBe(false) + }) + }) + + describe('isBinaryFile', () => { + it('returns true for binary file extensions', () => { + expect(isBinaryFile('image.png')).toBe(true) + expect(isBinaryFile('document.pdf')).toBe(true) + expect(isBinaryFile('archive.zip')).toBe(true) + expect(isBinaryFile('photo.jpg')).toBe(true) + }) + + it('returns false for text files', () => { + expect(isBinaryFile('script.js')).toBe(false) + expect(isBinaryFile('document.txt')).toBe(false) + expect(isBinaryFile('README.md')).toBe(false) + }) + + it('returns false for code files', () => { + expect(isBinaryFile('app.py')).toBe(false) + expect(isBinaryFile('style.css')).toBe(false) + expect(isBinaryFile('config.json')).toBe(false) + }) + }) +}) diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts new file mode 100644 index 00000000..7d30cdd8 --- /dev/null +++ b/src/utils/format.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { formatDate, formatFileCount, formatNumber } from '@/utils/format' + +describe('format', () => { + describe('formatNumber', () => { + it('formats numbers with comma separators', () => { + expect(formatNumber(1234567)).toBe('1,234,567') + expect(formatNumber(1000)).toBe('1,000') + expect(formatNumber(100)).toBe('100') + }) + + it('formats zero correctly', () => { + expect(formatNumber(0)).toBe('0') + }) + + it('formats negative numbers', () => { + expect(formatNumber(-1234)).toBe('-1,234') + }) + + it('formats single digit numbers', () => { + expect(formatNumber(5)).toBe('5') + }) + }) + + describe('formatDate', () => { + it('formats Unix timestamp to readable date', () => { + // January 1, 2024 00:00:00 UTC + const timestamp = 1704067200 + const result = formatDate(timestamp) + + expect(result).toContain('2024') + expect(result).toContain('January') + }) + + it('formats different timestamps correctly', () => { + // June 15, 2024 00:00:00 UTC + const timestamp = 1718409600 + const result = formatDate(timestamp) + + expect(result).toContain('2024') + expect(result).toContain('June') + }) + + it('handles timestamp zero', () => { + const result = formatDate(0) + expect(result).toContain('1970') + }) + }) + + describe('formatFileCount', () => { + it('formats single item', () => { + expect(formatFileCount(1)).toBe('1 item') + }) + + it('formats multiple items', () => { + expect(formatFileCount(5)).toBe('5 items') + expect(formatFileCount(1234)).toBe('1,234 items') + }) + + it('returns empty string for zero', () => { + expect(formatFileCount(0)).toBe('') + }) + + it('returns empty string for negative numbers', () => { + expect(formatFileCount(-5)).toBe('') + }) + + it('returns empty string for null', () => { + expect(formatFileCount(null)).toBe('') + }) + + it('returns empty string for undefined', () => { + expect(formatFileCount(undefined)).toBe('') + }) + + it('formats large numbers with commas', () => { + expect(formatFileCount(10000)).toBe('10,000 items') + }) + }) +}) diff --git a/src/utils/markdown.test.ts b/src/utils/markdown.test.ts new file mode 100644 index 00000000..b155137f --- /dev/null +++ b/src/utils/markdown.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { renderMarkdown } from '@/utils/markdown' + +describe('markdown', () => { + describe('renderMarkdown', () => { + it('returns empty string for empty input', () => { + expect(renderMarkdown('')).toBe('') + }) + + it('escapes HTML to prevent XSS', () => { + const result = renderMarkdown('') + expect(result).not.toContain('' + const result = resolveHtmlPaths(html, baseUrl, routePath) + expect(result).toBe(html) + }) + + it('preserves data URLs', () => { + const html = '' + const result = resolveHtmlPaths(html, baseUrl, routePath) + expect(result).toBe(html) + }) + + it('handles multiple relative paths in same HTML', () => { + const html = '' + const result = resolveHtmlPaths(html, baseUrl, routePath) + expect(result).toContain(`${baseUrl}${routePath}/`) + }) + + it('handles nested relative paths', () => { + const html = '' + const result = resolveHtmlPaths(html, baseUrl, routePath) + expect(result).toContain(`src="${baseUrl}${routePath}/`) + }) + + it('returns unchanged HTML for HTML without paths', () => { + const html = '

Plain text without any paths

' + const result = resolveHtmlPaths(html, baseUrl, routePath) + expect(result).toBe(html) + }) + }) +}) diff --git a/src/utils/sort.test.ts b/src/utils/sort.test.ts new file mode 100644 index 00000000..75ae66df --- /dev/null +++ b/src/utils/sort.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_SORT_OPTION, SORT_OPTIONS_GROUPED, sortEntities } from '@/utils/sort' +import type { SortableEntity } from '@/types/common' + +describe('sort', () => { + const mockEntities: SortableEntity[] = [ + { + entity: { + id: 3, + description: 'Zebra model', + created_ts: 1704067200, // 2024-01-01 + }, + entity_alias: 'zebra', + }, + { + entity: { + id: 1, + description: 'Apple model', + created_ts: 1704153600, // 2024-01-02 + }, + entity_alias: 'apple', + }, + { + entity: { + id: 2, + description: null, + created_ts: 1704240000, // 2024-01-03 + }, + entity_alias: 'no-desc', + }, + ] + + describe('SORT_OPTIONS_GROUPED', () => { + it('exports grouped sort options', () => { + expect(SORT_OPTIONS_GROUPED).toBeDefined() + expect(SORT_OPTIONS_GROUPED).toHaveLength(2) + expect(SORT_OPTIONS_GROUPED[0].group).toBe('Fields') + expect(SORT_OPTIONS_GROUPED[1].group).toBe('Direction') + }) + + it('has field options', () => { + const fieldsGroup = SORT_OPTIONS_GROUPED.find(g => g.group === 'Fields') + expect(fieldsGroup).toBeDefined() + expect(fieldsGroup?.options.length).toBeGreaterThan(0) + }) + + it('has direction options', () => { + const directionGroup = SORT_OPTIONS_GROUPED.find(g => g.group === 'Direction') + expect(directionGroup).toBeDefined() + expect(directionGroup?.options).toContainEqual({ + value: 'asc', + label: 'Ascending', + type: 'direction', + }) + expect(directionGroup?.options).toContainEqual({ + value: 'desc', + label: 'Descending', + type: 'direction', + }) + }) + }) + + describe('DEFAULT_SORT_OPTION', () => { + it('exports default sort option', () => { + expect(DEFAULT_SORT_OPTION).toBe('description-asc') + }) + }) + + describe('sortEntities', () => { + it('sorts by description ascending', () => { + const sorted = sortEntities(mockEntities, 'description-asc') + expect(sorted[0].entity.description).toBe('Apple model') + expect(sorted[1].entity.description).toBe('Zebra model') + expect(sorted[2].entity.description).toBeNull() + }) + + it('sorts by description descending', () => { + const sorted = sortEntities(mockEntities, 'description-desc') + expect(sorted[0].entity.description).toBe('Zebra model') + expect(sorted[1].entity.description).toBe('Apple model') + expect(sorted[2].entity.description).toBeNull() + }) + + it('sorts by id ascending', () => { + const sorted = sortEntities(mockEntities, 'id-asc') + expect(sorted[0].entity.id).toBe(1) + expect(sorted[1].entity.id).toBe(2) + expect(sorted[2].entity.id).toBe(3) + }) + + it('sorts by id descending', () => { + const sorted = sortEntities(mockEntities, 'id-desc') + expect(sorted[0].entity.id).toBe(3) + expect(sorted[1].entity.id).toBe(2) + expect(sorted[2].entity.id).toBe(1) + }) + + it('sorts by date ascending', () => { + const sorted = sortEntities(mockEntities, 'date-asc') + expect(sorted[0].entity.created_ts).toBe(1704067200) + expect(sorted[1].entity.created_ts).toBe(1704153600) + expect(sorted[2].entity.created_ts).toBe(1704240000) + }) + + it('sorts by date descending', () => { + const sorted = sortEntities(mockEntities, 'date-desc') + expect(sorted[0].entity.created_ts).toBe(1704240000) + expect(sorted[1].entity.created_ts).toBe(1704153600) + expect(sorted[2].entity.created_ts).toBe(1704067200) + }) + + it('handles null descriptions correctly', () => { + const sorted = sortEntities(mockEntities, 'description-asc') + // Null descriptions should come last + expect(sorted[sorted.length - 1].entity.description).toBeNull() + }) + + it('does not mutate original array', () => { + const original = [...mockEntities] + sortEntities(mockEntities, 'id-asc') + expect(mockEntities).toEqual(original) + }) + + it('returns new array', () => { + const sorted = sortEntities(mockEntities, 'id-asc') + expect(sorted).not.toBe(mockEntities) + }) + }) +})