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: '{{ title }}
',
+ props: ['to', 'title', 'description'],
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('Workspaces')
+ })
+
+ it('renders NavigationCard for Exposures', () => {
+ const wrapper = mount(Home, {
+ global: {
+ stubs: {
+ NavigationCard: {
+ template: '{{ title }}
',
+ 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)
+ })
+ })
+})