diff --git a/frontend/taskdeck-web/src/tests/components/ActivityResults.spec.ts b/frontend/taskdeck-web/src/tests/components/ActivityResults.spec.ts new file mode 100644 index 000000000..08a7ed78e --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ActivityResults.spec.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { reactive, ref, computed } from 'vue' +import ActivityResults from '../../components/activity/ActivityResults.vue' + +const mockAuditStore = reactive({ + loading: false, + entries: [] as Array<{ + action: string | number + timestamp: string + entityType: string + entityId: string + userName: string | null + changes: string | null + }>, +}) + +vi.mock('../../store/auditStore', () => ({ + useAuditStore: () => mockAuditStore, +})) + +// Mock the virtual list composable to render items directly +vi.mock('../../composables/useVirtualList', () => ({ + useVirtualList: (options: { count: { value: number } }) => ({ + parentRef: ref(null), + virtualItemEls: ref([]), + virtualRows: computed(() => + Array.from({ length: options.count.value }, (_, i) => ({ + index: i, + key: i, + start: i * 100, + end: (i + 1) * 100, + size: 100, + })), + ), + totalSize: computed(() => options.count.value * 100), + translateY: computed(() => 0), + }), +})) + +vi.mock('../../composables/useActivityQuery', () => ({ + formatAction: (action: string | number) => (typeof action === 'string' ? action : `Action${action}`), + formatTimestamp: (ts: string) => ts, +})) + +describe('ActivityResults', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuditStore.loading = false + mockAuditStore.entries = [] + }) + + it('shows loading state', () => { + mockAuditStore.loading = true + const wrapper = mount(ActivityResults, { + props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing here.' }, + }) + expect(wrapper.text()).toContain('Loading activity...') + }) + + it('shows empty state with custom title and body', () => { + const wrapper = mount(ActivityResults, { + props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing here yet.' }, + }) + expect(wrapper.text()).toContain('No Activity') + expect(wrapper.text()).toContain('Nothing here yet.') + }) + + it('shows action buttons in empty state', () => { + const wrapper = mount(ActivityResults, { + props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing.' }, + }) + expect(wrapper.text()).toContain('Open Review') + expect(wrapper.text()).toContain('Open Boards') + }) + + it('emits navigate when empty state action buttons are clicked', async () => { + const wrapper = mount(ActivityResults, { + props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing.' }, + }) + const reviewBtn = wrapper.findAll('button').find((b) => b.text() === 'Open Review') + expect(reviewBtn).toBeTruthy() + await reviewBtn?.trigger('click') + expect(wrapper.emitted('navigate')?.[0]).toEqual(['/workspace/review']) + + const boardsBtn = wrapper.findAll('button').find((b) => b.text() === 'Open Boards') + expect(boardsBtn).toBeTruthy() + await boardsBtn?.trigger('click') + expect(wrapper.emitted('navigate')?.[1]).toEqual(['/workspace/boards']) + }) + + it('renders timeline entries', () => { + mockAuditStore.entries = [ + { + action: 'Created', + timestamp: '2026-03-15T10:00:00Z', + entityType: 'Card', + entityId: 'card-1', + userName: 'alice', + changes: null, + }, + { + action: 1, + timestamp: '2026-03-15T11:00:00Z', + entityType: 'Board', + entityId: 'board-1', + userName: 'bob', + changes: 'Updated name', + }, + ] + const wrapper = mount(ActivityResults, { + props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing.' }, + }) + expect(wrapper.text()).toContain('Created') + expect(wrapper.text()).toContain('Card - card-1') + expect(wrapper.text()).toContain('by alice') + expect(wrapper.text()).toContain('Action1') + expect(wrapper.text()).toContain('Board - board-1') + expect(wrapper.text()).toContain('by bob') + expect(wrapper.text()).toContain('Updated name') + }) + + it('does not show actor when userName is null', () => { + mockAuditStore.entries = [ + { + action: 'Archived', + timestamp: '2026-03-15T10:00:00Z', + entityType: 'Card', + entityId: 'card-2', + userName: null, + changes: null, + }, + ] + const wrapper = mount(ActivityResults, { + props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing.' }, + }) + expect(wrapper.text()).not.toContain('by') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ActivitySelector.spec.ts b/frontend/taskdeck-web/src/tests/components/ActivitySelector.spec.ts new file mode 100644 index 000000000..dfbad59ca --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ActivitySelector.spec.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { reactive } from 'vue' +import ActivitySelector from '../../components/activity/ActivitySelector.vue' + +const mockSessionStore = reactive({ + username: 'alice', +}) + +vi.mock('../../store/sessionStore', () => ({ + useSessionStore: () => mockSessionStore, +})) + +const defaultProps = { + viewMode: 'board' as const, + selectedBoardId: '', + selectedEntityType: '' as const, + selectedEntityBoardId: '', + selectedEntityId: '', + limit: 50, + loadingEntitySource: false, + boardOptions: [ + { id: 'b1', label: 'Board One' }, + { id: 'b2', label: 'Board Two' }, + ], + requiresEntityBoardContext: false, + entityOptions: [], + canFetch: true, + selectedIdForCopy: '', + selectedIdLabel: '', +} + +describe('ActivitySelector', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSessionStore.username = 'alice' + }) + + it('renders view mode selector with all options', () => { + const wrapper = mount(ActivitySelector, { props: defaultProps }) + const modeSelect = wrapper.find('#activity-view-mode') + expect(modeSelect.exists()).toBe(true) + const options = modeSelect.findAll('option') + expect(options).toHaveLength(3) + expect(options[0].text()).toBe('Board History') + expect(options[1].text()).toBe('Entity History') + expect(options[2].text()).toBe('User History') + }) + + it('emits update:viewMode when view mode changes', async () => { + const wrapper = mount(ActivitySelector, { props: defaultProps }) + await wrapper.find('#activity-view-mode').setValue('entity') + expect(wrapper.emitted('update:viewMode')?.[0]).toEqual(['entity']) + }) + + it('shows board selector when viewMode is "board"', () => { + const wrapper = mount(ActivitySelector, { props: defaultProps }) + const boardSelect = wrapper.find('#activity-board-select') + expect(boardSelect.exists()).toBe(true) + const options = boardSelect.findAll('option') + // 1 disabled placeholder + 2 board options + expect(options).toHaveLength(3) + expect(options[1].text()).toBe('Board One') + expect(options[2].text()).toBe('Board Two') + }) + + it('emits update:selectedBoardId when board is changed', async () => { + const wrapper = mount(ActivitySelector, { props: defaultProps }) + await wrapper.find('#activity-board-select').setValue('b2') + expect(wrapper.emitted('update:selectedBoardId')?.[0]).toEqual(['b2']) + }) + + it('shows current user info when viewMode is "user"', () => { + const wrapper = mount(ActivitySelector, { + props: { ...defaultProps, viewMode: 'user' as const }, + }) + expect(wrapper.text()).toContain('Current user:') + expect(wrapper.text()).toContain('alice') + // Board selector should NOT be shown in user mode + expect(wrapper.find('#activity-board-select').exists()).toBe(false) + }) + + it('shows entity type selector when viewMode is "entity"', () => { + const wrapper = mount(ActivitySelector, { + props: { ...defaultProps, viewMode: 'entity' as const }, + }) + const entityTypeSelect = wrapper.find('#activity-entity-type') + expect(entityTypeSelect.exists()).toBe(true) + const options = entityTypeSelect.findAll('option') + // placeholder + Board, Column, Card, Label + expect(options).toHaveLength(5) + }) + + it('emits update:selectedEntityType when entity type changes', async () => { + const wrapper = mount(ActivitySelector, { + props: { ...defaultProps, viewMode: 'entity' as const }, + }) + await wrapper.find('#activity-entity-type').setValue('Card') + expect(wrapper.emitted('update:selectedEntityType')?.[0]).toEqual(['Card']) + }) + + it('shows limit selector with all options', () => { + const wrapper = mount(ActivitySelector, { props: defaultProps }) + const limitSelect = wrapper.find('[aria-label="Activity limit"]') + expect(limitSelect.exists()).toBe(true) + const options = limitSelect.findAll('option') + expect(options).toHaveLength(3) + expect(options.map((o) => o.text())).toEqual(['25', '50', '100']) + }) + + it('emits fetch when Fetch button is clicked', async () => { + const wrapper = mount(ActivitySelector, { props: defaultProps }) + const fetchBtn = wrapper.findAll('button').find((b) => b.text() === 'Fetch') + expect(fetchBtn).toBeTruthy() + await fetchBtn?.trigger('click') + expect(wrapper.emitted('fetch')).toHaveLength(1) + }) + + it('disables Fetch button when canFetch is false', () => { + const wrapper = mount(ActivitySelector, { + props: { ...defaultProps, canFetch: false }, + }) + const fetchBtn = wrapper.findAll('button').find((b) => b.text() === 'Fetch') + expect(fetchBtn).toBeTruthy() + expect(fetchBtn?.attributes('disabled')).toBeDefined() + }) + + it('shows loading helper text when loadingEntitySource is true', () => { + const wrapper = mount(ActivitySelector, { + props: { ...defaultProps, viewMode: 'entity' as const, loadingEntitySource: true }, + }) + expect(wrapper.text()).toContain('Loading entities for selected board...') + }) + + it('shows copy ID affordance when selectedIdForCopy is set', () => { + const wrapper = mount(ActivitySelector, { + props: { ...defaultProps, selectedIdForCopy: 'abc-123', selectedIdLabel: 'Board ID' }, + }) + expect(wrapper.text()).toContain('Board ID') + expect(wrapper.text()).toContain('abc-123') + expect(wrapper.text()).toContain('Copy Raw ID') + }) + + it('emits copyId when Copy Raw ID button is clicked', async () => { + const wrapper = mount(ActivitySelector, { + props: { ...defaultProps, selectedIdForCopy: 'abc-123', selectedIdLabel: 'Board ID' }, + }) + const copyBtn = wrapper.findAll('button').find((b) => b.text().includes('Copy Raw ID')) + expect(copyBtn).toBeTruthy() + await copyBtn?.trigger('click') + expect(wrapper.emitted('copyId')).toHaveLength(1) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/KeyboardShortcutsHelp.spec.ts b/frontend/taskdeck-web/src/tests/components/KeyboardShortcutsHelp.spec.ts new file mode 100644 index 000000000..de4ce4dcf --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/KeyboardShortcutsHelp.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import KeyboardShortcutsHelp from '../../components/KeyboardShortcutsHelp.vue' + +vi.mock('../../composables/useEscapeToClose', () => ({ + useEscapeToClose: vi.fn(), +})) + +function mountHelp(isOpen: boolean) { + return mount(KeyboardShortcutsHelp, { + props: { isOpen }, + attachTo: document.body, + }) +} + +function bodyText() { + return document.body.textContent ?? '' +} + +describe('KeyboardShortcutsHelp', () => { + it('renders nothing when isOpen is false', () => { + const wrapper = mountHelp(false) + expect(document.body.querySelector('[role="dialog"]')).toBeNull() + wrapper.unmount() + }) + + it('renders dialog when isOpen is true', () => { + const wrapper = mountHelp(true) + const dialog = document.body.querySelector('[role="dialog"]') as HTMLElement + expect(dialog).not.toBeNull() + expect(dialog.getAttribute('aria-label')).toBe('Keyboard Shortcuts') + expect(dialog.getAttribute('aria-modal')).toBe('true') + wrapper.unmount() + }) + + it('displays all four shortcut categories', () => { + const wrapper = mountHelp(true) + const text = bodyText() + expect(text).toContain('Navigation') + expect(text).toContain('Card Movement') + expect(text).toContain('Actions') + expect(text).toContain('General') + wrapper.unmount() + }) + + it('displays navigation shortcuts', () => { + const wrapper = mountHelp(true) + const text = bodyText() + expect(text).toContain('Select next card') + expect(text).toContain('Select previous card') + expect(text).toContain('Move to previous column') + expect(text).toContain('Move to next column') + wrapper.unmount() + }) + + it('displays card movement shortcuts', () => { + const wrapper = mountHelp(true) + const text = bodyText() + expect(text).toContain('Alt + ArrowRight') + expect(text).toContain('Move card to next column') + expect(text).toContain('Alt + ArrowUp') + expect(text).toContain('Move card up in column') + wrapper.unmount() + }) + + it('displays action shortcuts with Enter and n keys', () => { + const wrapper = mountHelp(true) + const text = bodyText() + expect(text).toContain('Open selected card') + expect(text).toContain('Create new card in current column') + wrapper.unmount() + }) + + it('displays general shortcuts including ? key', () => { + const wrapper = mountHelp(true) + const text = bodyText() + expect(text).toContain('Toggle this help dialog') + expect(text).toContain('Toggle filter panel') + wrapper.unmount() + }) + + it('emits close when close button is clicked', async () => { + const wrapper = mountHelp(true) + const closeBtn = document.body.querySelector('button[aria-label="Close"]') as HTMLElement + expect(closeBtn).not.toBeNull() + closeBtn.click() + await wrapper.vm.$nextTick() + expect(wrapper.emitted('close')).toHaveLength(1) + wrapper.unmount() + }) + + it('emits close when "Got it!" button is clicked', async () => { + const wrapper = mountHelp(true) + const buttons = document.body.querySelectorAll('button') + const gotItBtn = Array.from(buttons).find((b) => b.textContent?.includes('Got it!')) + expect(gotItBtn).toBeTruthy() + gotItBtn?.click() + await wrapper.vm.$nextTick() + expect(wrapper.emitted('close')).toHaveLength(1) + wrapper.unmount() + }) + + it('shows footer hint about the ? key', () => { + const wrapper = mountHelp(true) + const text = bodyText() + expect(text).toContain('anytime to show or hide this help') + wrapper.unmount() + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ShellKeyboardHelp.spec.ts b/frontend/taskdeck-web/src/tests/components/ShellKeyboardHelp.spec.ts new file mode 100644 index 000000000..270d3c702 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ShellKeyboardHelp.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import ShellKeyboardHelp from '../../components/shell/ShellKeyboardHelp.vue' + +function mountHelp(visible: boolean) { + return mount(ShellKeyboardHelp, { + props: { visible }, + attachTo: document.body, + }) +} + +function bodyText() { + return document.body.textContent ?? '' +} + +describe('ShellKeyboardHelp', () => { + it('renders nothing when visible is false', () => { + const wrapper = mountHelp(false) + expect(document.body.querySelector('.td-keyboard-help')).toBeNull() + wrapper.unmount() + }) + + it('renders keyboard shortcuts dialog when visible is true', () => { + const wrapper = mountHelp(true) + expect(document.body.querySelector('.td-keyboard-help')).not.toBeNull() + expect(bodyText()).toContain('Keyboard Shortcuts') + wrapper.unmount() + }) + + it('displays all shortcut sections (Global, Board Navigation, Editor)', () => { + const wrapper = mountHelp(true) + const text = bodyText() + expect(text).toContain('Global') + expect(text).toContain('Board Navigation') + expect(text).toContain('Editor') + wrapper.unmount() + }) + + it('displays key global shortcuts', () => { + const wrapper = mountHelp(true) + const text = bodyText() + expect(text).toContain('Ctrl+K') + expect(text).toContain('Command palette') + expect(text).toContain('Ctrl+Shift+C') + expect(text).toContain('Quick capture modal') + expect(text).toContain('Escape') + expect(text).toContain('Close top surface') + wrapper.unmount() + }) + + it('displays board navigation shortcuts', () => { + const wrapper = mountHelp(true) + const text = bodyText() + expect(text).toContain('h / Left') + expect(text).toContain('Previous column') + expect(text).toContain('j / Down') + expect(text).toContain('Next card') + expect(text).toContain('Enter') + expect(text).toContain('Open card') + wrapper.unmount() + }) + + it('emits close when close button is clicked', async () => { + const wrapper = mountHelp(true) + const closeBtn = document.body.querySelector('.td-keyboard-help__header button') as HTMLElement + expect(closeBtn).not.toBeNull() + closeBtn.click() + await wrapper.vm.$nextTick() + expect(wrapper.emitted('close')).toHaveLength(1) + wrapper.unmount() + }) + + it('has dialog role and aria-label for accessibility', () => { + const wrapper = mountHelp(true) + const overlay = document.body.querySelector('.td-overlay') as HTMLElement + expect(overlay).not.toBeNull() + expect(overlay.getAttribute('role')).toBe('dialog') + expect(overlay.getAttribute('aria-label')).toBe('Keyboard shortcuts') + expect(overlay.getAttribute('aria-modal')).toBe('true') + wrapper.unmount() + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ShellSidebar.spec.ts b/frontend/taskdeck-web/src/tests/components/ShellSidebar.spec.ts new file mode 100644 index 000000000..329a31a9f --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ShellSidebar.spec.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { reactive } from 'vue' +import ShellSidebar from '../../components/shell/ShellSidebar.vue' + +const mockFeatureFlags = reactive({ + isEnabled: vi.fn((_flag: string) => true), +}) + +const mockWorkspaceStore = reactive({ + mode: 'guided' as string, + inboxBadgeCount: 0, + reviewBadgeCount: 0, +}) + +vi.mock('../../store/featureFlagStore', () => ({ + useFeatureFlagStore: () => mockFeatureFlags, +})) + +vi.mock('../../store/workspaceStore', () => ({ + useWorkspaceStore: () => mockWorkspaceStore, +})) + +vi.mock('../../composables/useEscapeStack', () => ({ + registerEscapeHandler: vi.fn(() => vi.fn()), +})) + +const routeMock = reactive({ + path: '/workspace/home', +}) + +vi.mock('vue-router', () => ({ + useRoute: () => routeMock, +})) + +const routerLinkStub = { template: '', props: ['to'] } + +function mountSidebar(overrides?: { isAuthenticated?: boolean; stub?: Record }) { + return mount(ShellSidebar, { + props: { isAuthenticated: overrides?.isAuthenticated ?? true }, + global: { stubs: { 'router-link': overrides?.stub ?? routerLinkStub } }, + }) +} + +describe('ShellSidebar', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWorkspaceStore.mode = 'guided' + mockWorkspaceStore.inboxBadgeCount = 0 + mockWorkspaceStore.reviewBadgeCount = 0 + mockFeatureFlags.isEnabled = vi.fn(() => true) + routeMock.path = '/workspace/home' + }) + + it('renders the Taskdeck brand title', () => { + const wrapper = mountSidebar() + expect(wrapper.text()).toContain('Taskdeck') + expect(wrapper.text()).toContain('Precision Mode Active') + }) + + it('renders primary nav items for guided mode', () => { + const wrapper = mountSidebar() + expect(wrapper.text()).toContain('Home') + expect(wrapper.text()).toContain('Today') + expect(wrapper.text()).toContain('Review') + expect(wrapper.text()).toContain('Boards') + expect(wrapper.text()).toContain('Inbox') + }) + + it('shows secondary nav items with Workbench Tools section label in guided mode', () => { + mockWorkspaceStore.mode = 'guided' + const wrapper = mountSidebar() + // In guided mode, items with secondaryModes including 'guided' appear as secondary + expect(wrapper.text()).toContain('Workbench Tools') + expect(wrapper.text()).toContain('Views') + expect(wrapper.text()).toContain('Notifications') + }) + + it('promotes all nav items to primary in workbench mode', () => { + mockWorkspaceStore.mode = 'workbench' + const wrapper = mountSidebar() + // In workbench mode, items with primaryModes=['workbench'] are primary, not secondary + expect(wrapper.text()).toContain('Metrics') + expect(wrapper.text()).toContain('Activity') + expect(wrapper.text()).toContain('Ops') + }) + + it('shows badge count on inbox when inboxBadgeCount > 0', () => { + mockWorkspaceStore.inboxBadgeCount = 5 + const wrapper = mountSidebar() + const badges = wrapper.findAll('.td-nav-badge') + const inboxBadge = badges.find((b) => b.text() === '5') + expect(inboxBadge).toBeDefined() + }) + + it('shows badge count on review when reviewBadgeCount > 0', () => { + mockWorkspaceStore.reviewBadgeCount = 3 + const wrapper = mountSidebar() + const badges = wrapper.findAll('.td-nav-badge') + const reviewBadge = badges.find((b) => b.text() === '3') + expect(reviewBadge).toBeDefined() + }) + + it('does not show badges when counts are zero', () => { + const wrapper = mountSidebar() + expect(wrapper.findAll('.td-nav-badge')).toHaveLength(0) + }) + + it('shows shortcuts button and emits show-keyboard-help on click', async () => { + const wrapper = mountSidebar() + const shortcutsBtn = wrapper.find('.td-nav-item--help') + expect(shortcutsBtn.exists()).toBe(true) + expect(shortcutsBtn.text()).toContain('Shortcuts') + await shortcutsBtn.trigger('click') + expect(wrapper.emitted('show-keyboard-help')).toHaveLength(1) + }) + + it('shows logout button when authenticated and emits logout on click', async () => { + const wrapper = mountSidebar() + const logoutBtn = wrapper.find('.td-nav-item--logout') + expect(logoutBtn.exists()).toBe(true) + expect(logoutBtn.attributes('aria-label')).toBe('Log out') + await logoutBtn.trigger('click') + expect(wrapper.emitted('logout')).toHaveLength(1) + }) + + it('hides logout button when not authenticated', () => { + const wrapper = mountSidebar({ isAuthenticated: false }) + expect(wrapper.find('.td-nav-item--logout').exists()).toBe(false) + }) + + it('toggles collapsed state when toggle button is clicked', async () => { + const wrapper = mountSidebar() + expect(wrapper.find('.td-sidebar--collapsed').exists()).toBe(false) + + const toggleBtn = wrapper.find('.td-sidebar__toggle') + await toggleBtn.trigger('click') + expect(wrapper.find('.td-sidebar--collapsed').exists()).toBe(true) + + // Labels should be hidden when collapsed + expect(wrapper.find('.td-nav-item__label').exists()).toBe(false) + }) + + it('hides feature-flagged items when flag is disabled', () => { + mockFeatureFlags.isEnabled = vi.fn((flag: string) => { + if (flag === 'newAutomation') return false + return true + }) + const wrapper = mountSidebar() + expect(wrapper.text()).not.toContain('Review') + }) + + it('shows feature-flagged items in workbench mode even when flag is disabled (workbenchBypassesFlag)', () => { + mockWorkspaceStore.mode = 'workbench' + mockFeatureFlags.isEnabled = vi.fn(() => false) + const wrapper = mountSidebar() + // Review has workbenchBypassesFlag=true so it should appear even with flag disabled + expect(wrapper.text()).toContain('Review') + }) + + it('has navigation landmark role', () => { + const wrapper = mountSidebar() + expect(wrapper.find('aside').attributes('role')).toBe('navigation') + expect(wrapper.find('aside').attributes('aria-label')).toBe('Main navigation') + }) + + it('highlights the active route with aria-current', () => { + routeMock.path = '/workspace/inbox' + const wrapper = mountSidebar({ + stub: { + template: '', + props: ['to'], + }, + }) + // The Inbox nav item should have td-nav-item--active class applied by the component + const navItems = wrapper.findAll('.td-nav-item') + const inboxItem = navItems.find((el) => el.text().includes('Inbox')) + expect(inboxItem).toBeTruthy() + expect(inboxItem!.classes()).toContain('td-nav-item--active') + }) + + it('exposes availableNavItems via defineExpose for command palette', () => { + const wrapper = mountSidebar() + const exposed = (wrapper.vm as unknown as { availableNavItems: Array<{ id: string }> }).availableNavItems + expect(Array.isArray(exposed)).toBe(true) + expect(exposed.length).toBeGreaterThan(0) + expect(exposed.some((item) => item.id === 'home')).toBe(true) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ShellTopbar.spec.ts b/frontend/taskdeck-web/src/tests/components/ShellTopbar.spec.ts new file mode 100644 index 000000000..616e01702 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ShellTopbar.spec.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { reactive } from 'vue' +import ShellTopbar from '../../components/shell/ShellTopbar.vue' + +const mockSessionStore = reactive({ + isAuthenticated: true, + username: 'alice', +}) + +const mockWorkspaceStore = reactive({ + mode: 'guided' as string, + updateMode: vi.fn(), +}) + +vi.mock('../../store/sessionStore', () => ({ + useSessionStore: () => mockSessionStore, +})) + +vi.mock('../../store/workspaceStore', () => ({ + useWorkspaceStore: () => mockWorkspaceStore, +})) + +describe('ShellTopbar', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSessionStore.isAuthenticated = true + mockSessionStore.username = 'alice' + mockWorkspaceStore.mode = 'guided' + }) + + it('renders workspace mode selector with all three options', () => { + const wrapper = mount(ShellTopbar) + const select = wrapper.find('#workspace-mode-select') + expect(select.exists()).toBe(true) + const options = select.findAll('option') + expect(options).toHaveLength(3) + expect(options[0].text()).toBe('Guided') + expect(options[1].text()).toBe('Workbench') + expect(options[2].text()).toBe('Agent') + }) + + it('shows correct description for guided mode', () => { + const wrapper = mount(ShellTopbar) + expect(wrapper.text()).toContain('Keep Home, Review, and board work front and center.') + }) + + it('shows correct description for workbench mode', () => { + mockWorkspaceStore.mode = 'workbench' + const wrapper = mount(ShellTopbar) + expect(wrapper.text()).toContain('Show the full shipped workspace') + }) + + it('calls updateMode when workspace mode is changed', async () => { + const wrapper = mount(ShellTopbar) + const select = wrapper.find('#workspace-mode-select') + await select.setValue('workbench') + expect(mockWorkspaceStore.updateMode).toHaveBeenCalledWith('workbench') + }) + + it('renders command palette trigger button', () => { + const wrapper = mount(ShellTopbar) + const paletteBtn = wrapper.find('.td-topbar__palette-trigger') + expect(paletteBtn.exists()).toBe(true) + expect(paletteBtn.text()).toContain('Go anywhere... (Ctrl+K)') + }) + + it('emits open-command-palette when palette trigger is clicked', async () => { + const wrapper = mount(ShellTopbar) + const paletteBtn = wrapper.find('.td-topbar__palette-trigger') + await paletteBtn.trigger('click') + expect(wrapper.emitted('open-command-palette')).toHaveLength(1) + }) + + it('shows username when authenticated', () => { + const wrapper = mount(ShellTopbar) + expect(wrapper.find('.td-topbar__user').text()).toBe('alice') + }) + + it('does not show username when not authenticated', () => { + mockSessionStore.isAuthenticated = false + const wrapper = mount(ShellTopbar) + expect(wrapper.find('.td-topbar__user').exists()).toBe(false) + }) + + it('shows system status indicator', () => { + const wrapper = mount(ShellTopbar) + expect(wrapper.find('.td-topbar__status-dot').exists()).toBe(true) + expect(wrapper.find('.td-topbar__status-label').text()).toBe('System Live') + }) + + it('has accessible label on workspace mode select', () => { + const wrapper = mount(ShellTopbar) + const select = wrapper.find('#workspace-mode-select') + expect(select.attributes('aria-label')).toBe('Workspace mode') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/SwUpdatePrompt.spec.ts b/frontend/taskdeck-web/src/tests/components/SwUpdatePrompt.spec.ts new file mode 100644 index 000000000..948f4267f --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/SwUpdatePrompt.spec.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import SwUpdatePrompt from '../../components/shell/SwUpdatePrompt.vue' + +// Capture the onNeedRefresh callback +let capturedOnNeedRefresh: (() => void) | undefined +const mockUpdateSW = vi.fn(async () => {}) + +vi.mock('virtual:pwa-register', () => ({ + registerSW: (options?: { onNeedRefresh?: () => void }) => { + capturedOnNeedRefresh = options?.onNeedRefresh + return mockUpdateSW + }, +})) + +describe('SwUpdatePrompt', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedOnNeedRefresh = undefined + }) + + it('does not show update prompt initially', () => { + const wrapper = mount(SwUpdatePrompt) + expect(wrapper.find('.td-sw-update').exists()).toBe(false) + }) + + it('shows update prompt when onNeedRefresh is called', async () => { + const wrapper = mount(SwUpdatePrompt) + expect(capturedOnNeedRefresh).toBeDefined() + + capturedOnNeedRefresh!() + await wrapper.vm.$nextTick() + + expect(wrapper.find('.td-sw-update').exists()).toBe(true) + expect(wrapper.text()).toContain('A new version of Taskdeck is available.') + }) + + it('shows Update now and dismiss buttons', async () => { + const wrapper = mount(SwUpdatePrompt) + capturedOnNeedRefresh!() + await wrapper.vm.$nextTick() + + const updateBtn = wrapper.find('.td-sw-update__btn--primary') + expect(updateBtn.exists()).toBe(true) + expect(updateBtn.text()).toBe('Update now') + + const dismissBtn = wrapper.find('.td-sw-update__btn--dismiss') + expect(dismissBtn.exists()).toBe(true) + expect(dismissBtn.attributes('aria-label')).toBe('Dismiss update notification') + }) + + it('calls updateSW and hides prompt when Update now is clicked', async () => { + const wrapper = mount(SwUpdatePrompt) + capturedOnNeedRefresh!() + await wrapper.vm.$nextTick() + + await wrapper.find('.td-sw-update__btn--primary').trigger('click') + await flushPromises() + + expect(mockUpdateSW).toHaveBeenCalled() + expect(wrapper.find('.td-sw-update').exists()).toBe(false) + }) + + it('hides prompt without updating when dismiss is clicked', async () => { + const wrapper = mount(SwUpdatePrompt) + capturedOnNeedRefresh!() + await wrapper.vm.$nextTick() + + await wrapper.find('.td-sw-update__btn--dismiss').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.find('.td-sw-update').exists()).toBe(false) + expect(mockUpdateSW).not.toHaveBeenCalled() + }) + + it('has status role and aria-live polite for accessibility', async () => { + const wrapper = mount(SwUpdatePrompt) + capturedOnNeedRefresh!() + await wrapper.vm.$nextTick() + + const updateEl = wrapper.find('.td-sw-update') + expect(updateEl.attributes('role')).toBe('status') + expect(updateEl.attributes('aria-live')).toBe('polite') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ToastContainer.spec.ts b/frontend/taskdeck-web/src/tests/components/ToastContainer.spec.ts new file mode 100644 index 000000000..7c3d96350 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ToastContainer.spec.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { reactive } from 'vue' +import ToastContainer from '../../components/common/ToastContainer.vue' +import type { Toast } from '../../store/toastStore' + +const mockToastStore = reactive({ + toasts: [] as Toast[], + remove: vi.fn(), +}) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => mockToastStore, +})) + +describe('ToastContainer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockToastStore.toasts = [] + }) + + it('renders nothing when there are no toasts', () => { + const wrapper = mount(ToastContainer) + // Container exists but no toast items + expect(wrapper.findAll('[role="alert"]')).toHaveLength(0) + expect(wrapper.text()).toBe('') + }) + + it('renders toast messages', () => { + mockToastStore.toasts = [ + { id: 't1', message: 'Board created', type: 'success', duration: 3000 }, + { id: 't2', message: 'Network error', type: 'error', duration: 5000 }, + ] + const wrapper = mount(ToastContainer) + expect(wrapper.text()).toContain('Board created') + expect(wrapper.text()).toContain('Network error') + }) + + it('applies error role="alert" for error toasts', () => { + mockToastStore.toasts = [ + { id: 't1', message: 'Failed', type: 'error', duration: 5000 }, + ] + const wrapper = mount(ToastContainer) + const errorToast = wrapper.find('[role="alert"]') + expect(errorToast.exists()).toBe(true) + expect(errorToast.text()).toContain('Failed') + }) + + it('does not apply role="alert" for non-error toasts', () => { + mockToastStore.toasts = [ + { id: 't1', message: 'Saved', type: 'success', duration: 3000 }, + ] + const wrapper = mount(ToastContainer) + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + }) + + it('calls remove when close button is clicked', async () => { + mockToastStore.toasts = [ + { id: 't1', message: 'Dismiss me', type: 'info', duration: 3000 }, + ] + const wrapper = mount(ToastContainer) + const closeBtn = wrapper.find('button[aria-label="Close"]') + expect(closeBtn.exists()).toBe(true) + await closeBtn.trigger('click') + expect(mockToastStore.remove).toHaveBeenCalledWith('t1') + }) + + it('renders success toast with success styling', () => { + mockToastStore.toasts = [ + { id: 't1', message: 'Done', type: 'success', duration: 3000 }, + ] + const wrapper = mount(ToastContainer) + // Success toast should have green-themed classes + const toastEl = wrapper.find('.bg-green-50') + expect(toastEl.exists()).toBe(true) + }) + + it('renders warning toast with warning styling', () => { + mockToastStore.toasts = [ + { id: 't1', message: 'Warning', type: 'warning', duration: 4000 }, + ] + const wrapper = mount(ToastContainer) + const toastEl = wrapper.find('.bg-yellow-50') + expect(toastEl.exists()).toBe(true) + }) + + it('has aria-live="polite" on the container', () => { + const wrapper = mount(ToastContainer) + expect(wrapper.find('[aria-live="polite"]').exists()).toBe(true) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/WorkspaceHelpCallout.spec.ts b/frontend/taskdeck-web/src/tests/components/WorkspaceHelpCallout.spec.ts new file mode 100644 index 000000000..fe72207c3 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/WorkspaceHelpCallout.spec.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { computed, ref } from 'vue' +import WorkspaceHelpCallout from '../../components/workspace/WorkspaceHelpCallout.vue' + +const visibleRef = ref(true) +const mockDismiss = vi.fn() +const mockReplay = vi.fn() + +vi.mock('../../composables/useWorkspaceHelp', () => ({ + useWorkspaceHelp: () => ({ + isVisible: computed(() => visibleRef.value), + isDismissed: computed(() => !visibleRef.value), + dismiss: mockDismiss, + replay: mockReplay, + }), +})) + +describe('WorkspaceHelpCallout', () => { + beforeEach(() => { + vi.clearAllMocks() + visibleRef.value = true + }) + + it('renders title, description, and eyebrow when visible', () => { + const wrapper = mount(WorkspaceHelpCallout, { + props: { + topic: 'home', + title: 'Welcome to Home', + description: 'This is your starting point.', + }, + }) + expect(wrapper.text()).toContain('What is this?') + expect(wrapper.text()).toContain('Welcome to Home') + expect(wrapper.text()).toContain('This is your starting point.') + }) + + it('uses custom eyebrow text', () => { + const wrapper = mount(WorkspaceHelpCallout, { + props: { + topic: 'today', + title: 'Today', + description: 'desc', + eyebrow: 'Quick start', + }, + }) + expect(wrapper.find('.td-help-callout__eyebrow').text()).toBe('Quick start') + }) + + it('calls dismiss when dismiss button is clicked', async () => { + const wrapper = mount(WorkspaceHelpCallout, { + props: { + topic: 'home', + title: 'Title', + description: 'Desc', + }, + }) + const dismissBtn = wrapper.findAll('button').find((b) => b.text().includes('Hide this guide')) + expect(dismissBtn).toBeTruthy() + await dismissBtn?.trigger('click') + expect(mockDismiss).toHaveBeenCalledTimes(1) + }) + + it('shows dismissed state with replay button when not visible', () => { + visibleRef.value = false + const wrapper = mount(WorkspaceHelpCallout, { + props: { + topic: 'inbox', + title: 'Inbox Guide', + description: 'Learn about inbox.', + }, + }) + expect(wrapper.text()).toContain('This page guide is hidden.') + expect(wrapper.text()).toContain('Show page guide') + }) + + it('calls replay when replay button is clicked in dismissed state', async () => { + visibleRef.value = false + const wrapper = mount(WorkspaceHelpCallout, { + props: { + topic: 'review', + title: 'Review Guide', + description: 'Learn about review.', + }, + }) + const replayBtn = wrapper.findAll('button').find((b) => b.text().includes('Show page guide')) + expect(replayBtn).toBeTruthy() + await replayBtn?.trigger('click') + expect(mockReplay).toHaveBeenCalledTimes(1) + }) + + it('renders default slot content when visible', () => { + const wrapper = mount(WorkspaceHelpCallout, { + props: { topic: 'home', title: 'Title', description: 'Desc' }, + slots: { default: '

Extra help content

' }, + }) + expect(wrapper.find('.td-help-callout__body').exists()).toBe(true) + expect(wrapper.text()).toContain('Extra help content') + }) + + it('renders actions slot when visible', () => { + const wrapper = mount(WorkspaceHelpCallout, { + props: { topic: 'home', title: 'Title', description: 'Desc' }, + slots: { actions: '' }, + }) + expect(wrapper.find('.td-help-callout__actions').exists()).toBe(true) + expect(wrapper.text()).toContain('Get Started') + }) + + it('uses custom dismiss label when visible', () => { + visibleRef.value = true + const wrapper = mount(WorkspaceHelpCallout, { + props: { + topic: 'board', + title: 'Board', + description: 'Desc', + dismissLabel: 'Got it', + }, + }) + const dismissBtn = wrapper.findAll('button').find((b) => b.text().includes('Got it')) + expect(dismissBtn).toBeTruthy() + }) + + it('uses custom replay label when dismissed', () => { + visibleRef.value = false + const wrapper = mount(WorkspaceHelpCallout, { + props: { + topic: 'board', + title: 'Board', + description: 'Desc', + replayLabel: 'Bring it back', + }, + }) + const replayBtn = wrapper.findAll('button').find((b) => b.text().includes('Bring it back')) + expect(replayBtn).toBeTruthy() + }) + + it('sets data-help-topic attribute on root element', () => { + const wrapper = mount(WorkspaceHelpCallout, { + props: { topic: 'today', title: 'Today', description: 'Desc' }, + }) + expect(wrapper.find('[data-help-topic="today"]').exists()).toBe(true) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdBadge.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdBadge.spec.ts new file mode 100644 index 000000000..32697aa73 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ui/TdBadge.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import TdBadge from '../../../components/ui/TdBadge.vue' + +describe('TdBadge', () => { + it('renders slot content', () => { + const wrapper = mount(TdBadge, { slots: { default: '3 items' } }) + expect(wrapper.text()).toBe('3 items') + }) + + it('applies default variant and md size classes by default', () => { + const wrapper = mount(TdBadge) + expect(wrapper.classes()).toContain('td-badge--default') + expect(wrapper.classes()).toContain('td-badge--md') + }) + + it.each(['default', 'primary', 'success', 'warning', 'error', 'info'] as const)( + 'applies %s variant class', + (variant) => { + const wrapper = mount(TdBadge, { props: { variant } }) + expect(wrapper.classes()).toContain(`td-badge--${variant}`) + }, + ) + + it.each(['sm', 'md'] as const)('applies %s size class', (size) => { + const wrapper = mount(TdBadge, { props: { size } }) + expect(wrapper.classes()).toContain(`td-badge--${size}`) + }) + + it('renders as a span element', () => { + const wrapper = mount(TdBadge) + expect(wrapper.element.tagName).toBe('SPAN') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdEmptyState.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdEmptyState.spec.ts new file mode 100644 index 000000000..3246a9eec --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ui/TdEmptyState.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import TdEmptyState from '../../../components/ui/TdEmptyState.vue' + +describe('TdEmptyState', () => { + it('renders the title', () => { + const wrapper = mount(TdEmptyState, { props: { title: 'No boards yet' } }) + expect(wrapper.text()).toContain('No boards yet') + expect(wrapper.find('.td-empty-state__title').text()).toBe('No boards yet') + }) + + it('renders description when provided', () => { + const wrapper = mount(TdEmptyState, { + props: { title: 'Nothing here', description: 'Create your first board to get started.' }, + }) + expect(wrapper.find('.td-empty-state__description').text()).toBe( + 'Create your first board to get started.', + ) + }) + + it('does not render description element when description is empty', () => { + const wrapper = mount(TdEmptyState, { props: { title: 'Empty' } }) + expect(wrapper.find('.td-empty-state__description').exists()).toBe(false) + }) + + it('renders icon slot when provided', () => { + const wrapper = mount(TdEmptyState, { + props: { title: 'Empty' }, + slots: { icon: 'ICON' }, + }) + expect(wrapper.find('.td-empty-state__icon').exists()).toBe(true) + expect(wrapper.find('[data-testid="custom-icon"]').text()).toBe('ICON') + }) + + it('does not render icon wrapper when no icon slot is provided', () => { + const wrapper = mount(TdEmptyState, { props: { title: 'Empty' } }) + expect(wrapper.find('.td-empty-state__icon').exists()).toBe(false) + }) + + it('renders action slot when provided', () => { + const wrapper = mount(TdEmptyState, { + props: { title: 'No data' }, + slots: { action: '' }, + }) + expect(wrapper.find('.td-empty-state__action').exists()).toBe(true) + expect(wrapper.find('.td-empty-state__action button').text()).toBe('Create Board') + }) + + it('does not render action wrapper when no action slot is provided', () => { + const wrapper = mount(TdEmptyState, { props: { title: 'Empty' } }) + expect(wrapper.find('.td-empty-state__action').exists()).toBe(false) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdInlineAlert.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdInlineAlert.spec.ts new file mode 100644 index 000000000..cc611d666 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ui/TdInlineAlert.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import TdInlineAlert from '../../../components/ui/TdInlineAlert.vue' + +describe('TdInlineAlert', () => { + it('renders slot content as alert message', () => { + const wrapper = mount(TdInlineAlert, { + slots: { default: 'Something went wrong.' }, + }) + expect(wrapper.text()).toContain('Something went wrong.') + }) + + it('applies info variant class by default', () => { + const wrapper = mount(TdInlineAlert) + expect(wrapper.classes()).toContain('td-inline-alert--info') + }) + + it.each(['info', 'success', 'warning', 'error'] as const)( + 'applies %s variant class', + (variant) => { + const wrapper = mount(TdInlineAlert, { props: { variant } }) + expect(wrapper.classes()).toContain(`td-inline-alert--${variant}`) + }, + ) + + it('has role="alert" for accessibility', () => { + const wrapper = mount(TdInlineAlert) + expect(wrapper.attributes('role')).toBe('alert') + }) + + it('does not show dismiss button by default', () => { + const wrapper = mount(TdInlineAlert) + expect(wrapper.find('.td-inline-alert__dismiss').exists()).toBe(false) + }) + + it('shows dismiss button when dismissible is true', () => { + const wrapper = mount(TdInlineAlert, { props: { dismissible: true } }) + const dismissBtn = wrapper.find('.td-inline-alert__dismiss') + expect(dismissBtn.exists()).toBe(true) + expect(dismissBtn.attributes('aria-label')).toBe('Dismiss alert') + }) + + it('emits dismiss event when dismiss button is clicked', async () => { + const wrapper = mount(TdInlineAlert, { props: { dismissible: true } }) + await wrapper.find('.td-inline-alert__dismiss').trigger('click') + expect(wrapper.emitted('dismiss')).toHaveLength(1) + }) + + it('does not emit dismiss when not dismissible', () => { + const wrapper = mount(TdInlineAlert) + expect(wrapper.emitted('dismiss')).toBeUndefined() + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdSkeleton.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdSkeleton.spec.ts new file mode 100644 index 000000000..d3821a5d6 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ui/TdSkeleton.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import TdSkeleton from '../../../components/ui/TdSkeleton.vue' + +describe('TdSkeleton', () => { + it('renders with default dimensions', () => { + const wrapper = mount(TdSkeleton) + const style = wrapper.attributes('style') + expect(style).toContain('width: 100%') + expect(style).toContain('height: 1rem') + }) + + it('applies custom width and height', () => { + const wrapper = mount(TdSkeleton, { props: { width: '200px', height: '3rem' } }) + const style = wrapper.attributes('style') + expect(style).toContain('width: 200px') + expect(style).toContain('height: 3rem') + }) + + it('applies rounded class by default', () => { + const wrapper = mount(TdSkeleton) + expect(wrapper.classes()).toContain('td-skeleton--rounded') + expect(wrapper.classes()).not.toContain('td-skeleton--circle') + }) + + it('applies circle class when circle prop is true', () => { + const wrapper = mount(TdSkeleton, { props: { circle: true } }) + expect(wrapper.classes()).toContain('td-skeleton--circle') + expect(wrapper.classes()).not.toContain('td-skeleton--rounded') + }) + + it('does not apply rounded class when circle is true, even with rounded default', () => { + const wrapper = mount(TdSkeleton, { props: { circle: true, rounded: true } }) + expect(wrapper.classes()).toContain('td-skeleton--circle') + expect(wrapper.classes()).not.toContain('td-skeleton--rounded') + }) + + it('does not apply rounded class when rounded is false', () => { + const wrapper = mount(TdSkeleton, { props: { rounded: false } }) + expect(wrapper.classes()).not.toContain('td-skeleton--rounded') + expect(wrapper.classes()).not.toContain('td-skeleton--circle') + }) + + it('sets aria-hidden="true" to hide from screen readers', () => { + const wrapper = mount(TdSkeleton) + expect(wrapper.attributes('aria-hidden')).toBe('true') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdSpinner.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdSpinner.spec.ts new file mode 100644 index 000000000..a9aa13361 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ui/TdSpinner.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import TdSpinner from '../../../components/ui/TdSpinner.vue' + +describe('TdSpinner', () => { + it('renders with default label "Loading"', () => { + const wrapper = mount(TdSpinner) + expect(wrapper.find('.td-spinner__label').text()).toBe('Loading') + }) + + it('renders with custom label', () => { + const wrapper = mount(TdSpinner, { props: { label: 'Saving...' } }) + expect(wrapper.find('.td-spinner__label').text()).toBe('Saving...') + }) + + it('applies md size class by default', () => { + const wrapper = mount(TdSpinner) + expect(wrapper.classes()).toContain('td-spinner--md') + }) + + it.each(['sm', 'md', 'lg'] as const)('applies %s size class', (size) => { + const wrapper = mount(TdSpinner, { props: { size } }) + expect(wrapper.classes()).toContain(`td-spinner--${size}`) + }) + + it('has role="status" for accessibility', () => { + const wrapper = mount(TdSpinner) + expect(wrapper.attributes('role')).toBe('status') + }) + + it('renders an SVG spinner element', () => { + const wrapper = mount(TdSpinner) + const svg = wrapper.find('.td-spinner__svg') + expect(svg.exists()).toBe(true) + expect(svg.attributes('aria-hidden')).toBe('true') + }) + + it('contains track and arc path elements', () => { + const wrapper = mount(TdSpinner) + expect(wrapper.find('.td-spinner__track').exists()).toBe(true) + expect(wrapper.find('.td-spinner__arc').exists()).toBe(true) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdTag.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdTag.spec.ts new file mode 100644 index 000000000..abd036c8a --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ui/TdTag.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import TdTag from '../../../components/ui/TdTag.vue' + +describe('TdTag', () => { + it('renders slot content as the tag label', () => { + const wrapper = mount(TdTag, { slots: { default: 'Bug' } }) + expect(wrapper.find('.td-tag__label').text()).toBe('Bug') + }) + + it('does not show remove button by default', () => { + const wrapper = mount(TdTag, { slots: { default: 'Feature' } }) + expect(wrapper.find('.td-tag__remove').exists()).toBe(false) + }) + + it('shows remove button when removable is true', () => { + const wrapper = mount(TdTag, { + props: { removable: true }, + slots: { default: 'Feature' }, + }) + const removeBtn = wrapper.find('.td-tag__remove') + expect(removeBtn.exists()).toBe(true) + expect(removeBtn.attributes('aria-label')).toBe('Remove tag') + }) + + it('emits remove event when remove button is clicked', async () => { + const wrapper = mount(TdTag, { + props: { removable: true }, + slots: { default: 'Label' }, + }) + await wrapper.find('.td-tag__remove').trigger('click') + expect(wrapper.emitted('remove')).toHaveLength(1) + }) + + it('applies custom color CSS variable when color prop is set', () => { + const wrapper = mount(TdTag, { + props: { color: '#ff6600' }, + slots: { default: 'Orange' }, + }) + expect(wrapper.classes()).toContain('td-tag--custom') + expect(wrapper.attributes('style')).toContain('--td-tag-color: #ff6600') + }) + + it('does not apply custom class or style when no color is set', () => { + const wrapper = mount(TdTag, { slots: { default: 'Plain' } }) + expect(wrapper.classes()).not.toContain('td-tag--custom') + expect(wrapper.attributes('style')).toBeUndefined() + }) + + it('renders as a span element', () => { + const wrapper = mount(TdTag, { slots: { default: 'Tag' } }) + expect(wrapper.element.tagName).toBe('SPAN') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdToast.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdToast.spec.ts new file mode 100644 index 000000000..3213312c6 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ui/TdToast.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import TdToast from '../../../components/ui/TdToast.vue' + +describe('TdToast', () => { + it('renders the message text', () => { + const wrapper = mount(TdToast, { props: { message: 'Board created' } }) + expect(wrapper.find('.td-toast__message').text()).toBe('Board created') + }) + + it('applies info variant class by default', () => { + const wrapper = mount(TdToast, { props: { message: 'Info message' } }) + expect(wrapper.classes()).toContain('td-toast--info') + }) + + it.each(['info', 'success', 'warning', 'error'] as const)( + 'applies %s variant class', + (variant) => { + const wrapper = mount(TdToast, { props: { message: 'msg', variant } }) + expect(wrapper.classes()).toContain(`td-toast--${variant}`) + }, + ) + + it('has role="status" and aria-live="polite" for accessibility', () => { + const wrapper = mount(TdToast, { props: { message: 'test' } }) + expect(wrapper.attributes('role')).toBe('status') + expect(wrapper.attributes('aria-live')).toBe('polite') + }) + + it('shows dismiss button by default (dismissible=true)', () => { + const wrapper = mount(TdToast, { props: { message: 'test' } }) + const dismissBtn = wrapper.find('.td-toast__dismiss') + expect(dismissBtn.exists()).toBe(true) + expect(dismissBtn.attributes('aria-label')).toBe('Dismiss') + }) + + it('hides dismiss button when dismissible is false', () => { + const wrapper = mount(TdToast, { props: { message: 'test', dismissible: false } }) + expect(wrapper.find('.td-toast__dismiss').exists()).toBe(false) + }) + + it('emits dismiss event when dismiss button is clicked', async () => { + const wrapper = mount(TdToast, { props: { message: 'test' } }) + await wrapper.find('.td-toast__dismiss').trigger('click') + expect(wrapper.emitted('dismiss')).toHaveLength(1) + }) +})