From db693168891d2bf0bac24f54395f7a37d0e2d232 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 13 Apr 2026 01:23:43 +0100 Subject: [PATCH 1/9] test: add ArchiveView coverage for item restore, loading, empty states Adds 11 test cases covering item restore flow (success, cancel, error, partial failure), empty states for items and boards, refresh action, and entity type badge display. Closes part of #716 --- .../tests/views/ArchiveView.coverage.spec.ts | 385 ++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/views/ArchiveView.coverage.spec.ts diff --git a/frontend/taskdeck-web/src/tests/views/ArchiveView.coverage.spec.ts b/frontend/taskdeck-web/src/tests/views/ArchiveView.coverage.spec.ts new file mode 100644 index 00000000..0ced7846 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/ArchiveView.coverage.spec.ts @@ -0,0 +1,385 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import ArchiveView from '../../views/ArchiveView.vue' + +const mocks = vi.hoisted(() => ({ + getItems: vi.fn(), + restoreItem: vi.fn(), + getBoards: vi.fn(), + updateBoard: vi.fn(), + successToast: vi.fn(), + errorToast: vi.fn(), +})) + +vi.mock('../../api/archiveApi', () => ({ + archiveApi: { + getItems: mocks.getItems, + restoreItem: mocks.restoreItem, + }, +})) + +vi.mock('../../api/boardsApi', () => ({ + boardsApi: { + getBoards: mocks.getBoards, + updateBoard: mocks.updateBoard, + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ + success: mocks.successToast, + error: mocks.errorToast, + }), +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (_error: unknown, fallback: string) => ({ message: fallback }), +})) + +async function waitForAsyncUi() { + await Promise.resolve() + await Promise.resolve() +} + +function findButtonByText(wrapper: ReturnType, text: string) { + return wrapper + .findAll('button') + .find((candidate) => candidate.text().includes(text)) +} + +describe('ArchiveView — item restore flow', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.getItems.mockResolvedValue([]) + mocks.getBoards.mockResolvedValue([]) + }) + + it('restores an archive item and removes it from the list', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + mocks.getItems.mockResolvedValue([ + { + id: 'item-1', + entityType: 'card', + entityId: 'card-1', + boardId: 'board-1', + name: 'Restorable Card', + archivedByUserId: 'user-1', + archivedAt: new Date().toISOString(), + reason: null, + restoreStatus: 'Available', + restoredAt: null, + restoredByUserId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + mocks.restoreItem.mockResolvedValue({ + success: true, + resolvedName: 'Restorable Card', + errorMessage: null, + }) + + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + expect(wrapper.text()).toContain('Restorable Card') + + const restoreButton = findButtonByText(wrapper, 'Restore') + expect(restoreButton).toBeDefined() + await restoreButton!.trigger('click') + await waitForAsyncUi() + + expect(mocks.restoreItem).toHaveBeenCalledWith('card', 'card-1', { + targetBoardId: null, + restoreMode: 0, + conflictStrategy: 0, + }) + expect(mocks.successToast).toHaveBeenCalledWith('Restored "Restorable Card"') + expect(wrapper.text()).not.toContain('Restorable Card') + + confirmSpy.mockRestore() + }) + + it('does not restore when user cancels the confirmation dialog', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + + mocks.getItems.mockResolvedValue([ + { + id: 'item-1', + entityType: 'card', + entityId: 'card-1', + boardId: 'board-1', + name: 'Card Not Restored', + archivedByUserId: 'user-1', + archivedAt: new Date().toISOString(), + reason: null, + restoreStatus: 'Available', + restoredAt: null, + restoredByUserId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + const restoreButton = findButtonByText(wrapper, 'Restore') + await restoreButton!.trigger('click') + await waitForAsyncUi() + + expect(mocks.restoreItem).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('Card Not Restored') + + confirmSpy.mockRestore() + }) + + it('shows error toast when item restore fails', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + mocks.getItems.mockResolvedValue([ + { + id: 'item-1', + entityType: 'column', + entityId: 'col-1', + boardId: 'board-1', + name: 'Failed Column', + archivedByUserId: 'user-1', + archivedAt: new Date().toISOString(), + reason: null, + restoreStatus: 'Available', + restoredAt: null, + restoredByUserId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + mocks.restoreItem.mockRejectedValue(new Error('network failure')) + + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + const restoreButton = findButtonByText(wrapper, 'Restore') + await restoreButton!.trigger('click') + await waitForAsyncUi() + + expect(mocks.errorToast).toHaveBeenCalledWith('Failed to restore archive item') + // Item remains in the list + expect(wrapper.text()).toContain('Failed Column') + + confirmSpy.mockRestore() + }) + + it('shows error toast when item restore returns success=false', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + mocks.getItems.mockResolvedValue([ + { + id: 'item-1', + entityType: 'card', + entityId: 'card-1', + boardId: 'board-1', + name: 'Conflicting Card', + archivedByUserId: 'user-1', + archivedAt: new Date().toISOString(), + reason: null, + restoreStatus: 'Available', + restoredAt: null, + restoredByUserId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + mocks.restoreItem.mockResolvedValue({ + success: false, + resolvedName: null, + errorMessage: 'Conflict detected', + }) + + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + const restoreButton = findButtonByText(wrapper, 'Restore') + await restoreButton!.trigger('click') + await waitForAsyncUi() + + expect(mocks.errorToast).toHaveBeenCalledWith('Conflict detected') + expect(wrapper.text()).toContain('Conflicting Card') + + confirmSpy.mockRestore() + }) +}) + +describe('ArchiveView — loading and empty states', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.getItems.mockResolvedValue([]) + mocks.getBoards.mockResolvedValue([]) + }) + + it('shows empty state for items when no archive items exist', async () => { + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + expect(wrapper.text()).toContain('No archived items found in recovery inventory.') + }) + + it('shows empty state for boards when no archived boards exist', async () => { + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + expect(wrapper.text()).toContain('No archived boards found.') + }) + + it('shows error toast when archive items loading fails', async () => { + mocks.getItems.mockRejectedValue(new Error('items request failed')) + + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + expect(mocks.errorToast).toHaveBeenCalledWith('Failed to load archive items') + expect(wrapper.text()).toContain('No archived items found in recovery inventory.') + }) + + it('renders the Refresh Items button and reloads items on click', async () => { + mocks.getItems.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { + id: 'item-2', + entityType: 'card', + entityId: 'card-2', + boardId: 'board-1', + name: 'Refreshed Card', + archivedByUserId: 'user-1', + archivedAt: new Date().toISOString(), + reason: null, + restoreStatus: 'Available', + restoredAt: null, + restoredByUserId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + expect(wrapper.text()).not.toContain('Refreshed Card') + + const refreshButton = findButtonByText(wrapper, 'Refresh Items') + expect(refreshButton).toBeDefined() + await refreshButton!.trigger('click') + await waitForAsyncUi() + + expect(mocks.getItems).toHaveBeenCalledTimes(2) + expect(wrapper.text()).toContain('Refreshed Card') + }) + + it('disables restore button for items with non-Available restore status', async () => { + mocks.getItems.mockResolvedValue([ + { + id: 'item-restored', + entityType: 'card', + entityId: 'card-restored', + boardId: 'board-1', + name: 'Already Restored', + archivedByUserId: 'user-1', + archivedAt: new Date().toISOString(), + reason: null, + restoreStatus: 'Restored', + restoredAt: new Date().toISOString(), + restoredByUserId: 'user-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + const restoreButton = findButtonByText(wrapper, 'Restore') + expect(restoreButton).toBeDefined() + expect(restoreButton!.attributes('disabled')).toBeDefined() + }) + + it('does not cancel board restore when confirm is cancelled', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + + mocks.getBoards.mockResolvedValue([ + { + id: 'board-archived', + name: 'Not Restored Board', + description: null, + isArchived: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + const restoreBoardButton = findButtonByText(wrapper, 'Restore Board') + await restoreBoardButton!.trigger('click') + await waitForAsyncUi() + + expect(mocks.updateBoard).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('Not Restored Board') + + confirmSpy.mockRestore() + }) +}) + +describe('ArchiveView — entity type badges', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.getItems.mockResolvedValue([]) + mocks.getBoards.mockResolvedValue([]) + }) + + it('displays entity type badge for each archive item', async () => { + mocks.getItems.mockResolvedValue([ + { + id: 'item-card', + entityType: 'card', + entityId: 'card-1', + boardId: 'board-1', + name: 'Card Item', + archivedByUserId: 'user-1', + archivedAt: new Date().toISOString(), + reason: null, + restoreStatus: 'Available', + restoredAt: null, + restoredByUserId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 'item-col', + entityType: 'column', + entityId: 'col-1', + boardId: 'board-1', + name: 'Column Item', + archivedByUserId: 'user-1', + archivedAt: new Date().toISOString(), + reason: null, + restoreStatus: 'Available', + restoredAt: null, + restoredByUserId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + + const wrapper = mount(ArchiveView) + await waitForAsyncUi() + + const badges = wrapper.findAll('.td-badge') + const badgeTexts = badges.map((b) => b.text().toLowerCase()) + expect(badgeTexts).toContain('card') + expect(badgeTexts).toContain('column') + }) +}) From a1aa674e4e135160f1aa90b40ec1d64440496ff1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 13 Apr 2026 01:23:57 +0100 Subject: [PATCH 2/9] test: add MetricsView coverage for forecast, export, retry, accessibility Adds 16 test cases covering retry button, CSV export (enabled/disabled/ error), forecast section (loading, error, data, confidence band, caveats, assumptions, null date), and ARIA accessibility attributes. Closes part of #716 --- .../tests/views/MetricsView.coverage.spec.ts | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/views/MetricsView.coverage.spec.ts diff --git a/frontend/taskdeck-web/src/tests/views/MetricsView.coverage.spec.ts b/frontend/taskdeck-web/src/tests/views/MetricsView.coverage.spec.ts new file mode 100644 index 00000000..914fe99e --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/MetricsView.coverage.spec.ts @@ -0,0 +1,323 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { reactive } from 'vue' +import MetricsView from '../../views/MetricsView.vue' +import type { BoardMetricsResponse, BoardForecastResponse } from '../../types/metrics' + +const MOCK_METRICS: BoardMetricsResponse = { + boardId: 'board-1', + from: '2026-03-01T00:00:00Z', + to: '2026-03-31T23:59:59Z', + throughput: [ + { date: '2026-03-15T00:00:00Z', completedCount: 3 }, + ], + averageCycleTimeDays: 2.5, + cycleTimeEntries: [], + wipSnapshots: [], + totalWip: 4, + blockedCount: 0, + blockedCards: [], +} + +const MOCK_FORECAST: BoardForecastResponse = { + boardId: 'board-1', + remainingCards: 12, + averageThroughputPerDay: 1.5, + estimatedCompletionDate: '2026-04-20T00:00:00Z', + historyDaysUsed: 30, + dataPointCount: 28, + assumptions: ['Throughput remains constant', 'No new cards added'], + caveats: ['Low data confidence'], + confidenceBand: { + lowEstimate: '2026-04-15T00:00:00Z', + expectedEstimate: '2026-04-20T00:00:00Z', + highEstimate: '2026-04-28T00:00:00Z', + lowThroughputPerDay: 0.8, + expectedThroughputPerDay: 1.5, + highThroughputPerDay: 2.2, + }, +} + +const mockMetricsStore = reactive({ + metrics: null as BoardMetricsResponse | null, + loading: false, + error: null as string | null, + forecast: null as BoardForecastResponse | null, + forecastLoading: false, + forecastError: null as string | null, + fetchBoardMetrics: vi.fn().mockResolvedValue(undefined), + fetchBoardForecast: vi.fn().mockResolvedValue(undefined), + $reset: vi.fn(), +}) + +const mockBoardStore = reactive({ + boards: [] as Array<{ id: string; name: string; isArchived: boolean; description: null; createdAt: string; updatedAt: string }>, + fetchBoards: vi.fn<(...args: unknown[]) => Promise>(), +}) + +const mockToastStore = reactive({ + error: vi.fn(), + success: vi.fn(), +}) + +const mockMetricsApiMethods = vi.hoisted(() => ({ + exportBoardMetricsCsv: vi.fn(), +})) + +vi.mock('../../store/metricsStore', () => ({ + useMetricsStore: () => mockMetricsStore, +})) + +vi.mock('../../store/boardStore', () => ({ + useBoardStore: () => mockBoardStore, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => mockToastStore, +})) + +vi.mock('../../api/metricsApi', () => ({ + metricsApi: { + exportBoardMetricsCsv: mockMetricsApiMethods.exportBoardMetricsCsv, + }, +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (_error: unknown, fallback: string) => ({ message: fallback }), +})) + +function seedBoards() { + mockBoardStore.boards = [ + { id: 'board-1', name: 'Board One', isArchived: false, description: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + { id: 'board-2', name: 'Board Two', isArchived: false, description: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + ] +} + +async function waitForUi() { + await flushPromises() + await flushPromises() +} + +describe('MetricsView — retry and export', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMetricsStore.metrics = null + mockMetricsStore.loading = false + mockMetricsStore.error = null + mockMetricsStore.forecast = null + mockMetricsStore.forecastLoading = false + mockMetricsStore.forecastError = null + mockBoardStore.boards = [] + mockBoardStore.fetchBoards.mockImplementation(async () => { seedBoards() }) + mockMetricsApiMethods.exportBoardMetricsCsv.mockResolvedValue(undefined) + }) + + it('calls fetchBoardMetrics when retry button is clicked in error state', async () => { + mockMetricsStore.error = 'Connection timeout' + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('Connection timeout') + + const retryBtn = wrapper.find('.td-metrics__state--error button') + expect(retryBtn.exists()).toBe(true) + + vi.clearAllMocks() + await retryBtn.trigger('click') + await waitForUi() + + expect(mockMetricsStore.fetchBoardMetrics).toHaveBeenCalled() + }) + + it('renders Export CSV button as disabled when no data is available', async () => { + const wrapper = mount(MetricsView) + await waitForUi() + + const exportBtn = wrapper.findAll('button').find((b) => b.text().includes('Export CSV')) + expect(exportBtn).toBeDefined() + expect(exportBtn!.attributes('disabled')).toBeDefined() + }) + + it('enables Export CSV button when metrics data is present', async () => { + mockMetricsStore.metrics = MOCK_METRICS + const wrapper = mount(MetricsView) + await waitForUi() + + const exportBtn = wrapper.findAll('button').find((b) => b.text().includes('Export CSV')) + expect(exportBtn).toBeDefined() + expect(exportBtn!.attributes('disabled')).toBeUndefined() + }) + + it('calls metricsApi.exportBoardMetricsCsv when Export CSV is clicked', async () => { + mockMetricsStore.metrics = MOCK_METRICS + const wrapper = mount(MetricsView) + await waitForUi() + + const exportBtn = wrapper.findAll('button').find((b) => b.text().includes('Export CSV')) + await exportBtn!.trigger('click') + await waitForUi() + + expect(mockMetricsApiMethods.exportBoardMetricsCsv).toHaveBeenCalledWith( + expect.objectContaining({ boardId: 'board-1' }), + ) + }) + + it('shows error toast when CSV export fails', async () => { + mockMetricsStore.metrics = MOCK_METRICS + mockMetricsApiMethods.exportBoardMetricsCsv.mockRejectedValueOnce(new Error('export failed')) + + const wrapper = mount(MetricsView) + await waitForUi() + + const exportBtn = wrapper.findAll('button').find((b) => b.text().includes('Export CSV')) + await exportBtn!.trigger('click') + await waitForUi() + + expect(mockToastStore.error).toHaveBeenCalledWith('Failed to export CSV') + }) +}) + +describe('MetricsView — forecast section', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMetricsStore.metrics = MOCK_METRICS + mockMetricsStore.loading = false + mockMetricsStore.error = null + mockMetricsStore.forecast = null + mockMetricsStore.forecastLoading = false + mockMetricsStore.forecastError = null + mockBoardStore.boards = [] + mockBoardStore.fetchBoards.mockImplementation(async () => { seedBoards() }) + }) + + it('shows forecast loading spinner when forecastLoading is true', async () => { + mockMetricsStore.forecastLoading = true + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('Computing forecast...') + expect(wrapper.find('.td-metrics__forecast-loading').exists()).toBe(true) + }) + + it('shows forecast error state with retry button', async () => { + mockMetricsStore.forecastError = 'Forecast computation failed' + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('Forecast computation failed') + const retryBtn = wrapper.find('.td-metrics__forecast-error button') + expect(retryBtn.exists()).toBe(true) + expect(retryBtn.text()).toBe('Retry') + }) + + it('renders forecast data with remaining cards, throughput, and estimated completion', async () => { + mockMetricsStore.forecast = MOCK_FORECAST + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('Completion Forecast') + expect(wrapper.text()).toContain('Remaining') + expect(wrapper.text()).toContain('12') + expect(wrapper.text()).toContain('Avg Throughput') + expect(wrapper.text()).toContain('1.50') + expect(wrapper.text()).toContain('Estimated Completion') + expect(wrapper.text()).toContain('Data Points') + expect(wrapper.text()).toContain('28') + expect(wrapper.text()).toContain('over 30 days') + }) + + it('renders confidence band with optimistic, expected, and pessimistic estimates', async () => { + mockMetricsStore.forecast = MOCK_FORECAST + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('Confidence Range') + expect(wrapper.text()).toContain('Optimistic') + expect(wrapper.text()).toContain('Expected') + expect(wrapper.text()).toContain('Pessimistic') + expect(wrapper.text()).toContain('2.20 cards/day') + expect(wrapper.text()).toContain('1.50 cards/day') + expect(wrapper.text()).toContain('0.80 cards/day') + }) + + it('renders caveats when present', async () => { + mockMetricsStore.forecast = MOCK_FORECAST + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('Caveats') + expect(wrapper.text()).toContain('Low data confidence') + }) + + it('renders assumptions section as expandable details', async () => { + mockMetricsStore.forecast = MOCK_FORECAST + const wrapper = mount(MetricsView) + await waitForUi() + + const details = wrapper.find('.td-metrics__assumptions') + expect(details.exists()).toBe(true) + expect(wrapper.text()).toContain('Assumptions (2)') + }) + + it('shows N/A for estimated completion when date is null', async () => { + mockMetricsStore.forecast = { + ...MOCK_FORECAST, + estimatedCompletionDate: null, + confidenceBand: null, + } + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('N/A') + }) +}) + +describe('MetricsView — accessibility', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMetricsStore.metrics = null + mockMetricsStore.loading = false + mockMetricsStore.error = null + mockMetricsStore.forecast = null + mockMetricsStore.forecastLoading = false + mockMetricsStore.forecastError = null + mockBoardStore.boards = [] + mockBoardStore.fetchBoards.mockImplementation(async () => { seedBoards() }) + }) + + it('has proper aria attributes on filter section', async () => { + const wrapper = mount(MetricsView) + await waitForUi() + + const filterSection = wrapper.find('[aria-label="Metric filters"]') + expect(filterSection.exists()).toBe(true) + }) + + it('marks loading state with role="status" and aria-live', async () => { + mockMetricsStore.loading = true + const wrapper = mount(MetricsView) + await waitForUi() + + const loadingDiv = wrapper.find('[role="status"]') + expect(loadingDiv.exists()).toBe(true) + expect(loadingDiv.attributes('aria-live')).toBe('polite') + }) + + it('marks error state with role="alert"', async () => { + mockMetricsStore.error = 'Some error' + const wrapper = mount(MetricsView) + await waitForUi() + + const errorDiv = wrapper.find('[role="alert"]') + expect(errorDiv.exists()).toBe(true) + }) + + it('has aria-label on throughput bar chart', async () => { + mockMetricsStore.metrics = MOCK_METRICS + const wrapper = mount(MetricsView) + await waitForUi() + + const chart = wrapper.find('[aria-label="Throughput bar chart"]') + expect(chart.exists()).toBe(true) + }) +}) From 4908b28d494929050a1000c8240c1cec40ddecf7 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 13 Apr 2026 01:24:02 +0100 Subject: [PATCH 3/9] test: add BoardView coverage for loading, error, column creation, cleanup Adds 12 test cases covering loading/error states, column creation flow (submit, empty name, cancel), column rendering order, fetchBoard and realtime lifecycle, and unmount cleanup. Closes part of #716 --- .../tests/views/BoardView.coverage.spec.ts | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts new file mode 100644 index 00000000..792e15a5 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts @@ -0,0 +1,333 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { reactive } from 'vue' +import BoardView from '../../views/BoardView.vue' + +const mockSessionStore = reactive<{ userId: string | null; username: string | null }>({ + userId: 'user-abc', + username: 'alice', +}) + +vi.mock('../../store/sessionStore', () => ({ + useSessionStore: () => mockSessionStore, +})) + +const routerMock = vi.hoisted(() => ({ + push: vi.fn(), +})) + +const routeMock = reactive({ + params: { id: 'board-1' }, +}) + +const realtimeMock = { + start: vi.fn(async () => {}), + switchBoard: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + setEditingCard: vi.fn(async () => {}), +} + +const mockBoardStore = reactive({ + currentBoard: null as { + id: string + name: string + description: string | null + columns: Array<{ + id: string + boardId: string + name: string + position: number + wipLimit: number | null + createdAt: string + updatedAt: string + }> + createdAt: string + updatedAt: string + } | null, + currentBoardLabels: [], + cardsByColumn: new Map(), + boardPresenceMembers: [] as Array<{ userId: string }>, + editingCardId: null as string | null, + loading: false, + error: null as string | null, + filters: { + search: '', + labelIds: [], + onlyBlocked: false, + dueBefore: '', + dueAfter: '', + }, + filteredCardCount: 0, + totalCardCount: 0, + fetchBoard: vi.fn(async () => {}), + setBoardPresenceMembers: vi.fn(), + setEditingCard: vi.fn(), + createColumn: vi.fn(async () => {}), + reorderColumns: vi.fn(async () => {}), + updateFilters: vi.fn(), +}) + +vi.mock('vue-router', () => ({ + useRoute: () => routeMock, + useRouter: () => routerMock, +})) + +vi.mock('../../store/boardStore', () => ({ + useBoardStore: () => mockBoardStore, +})) + +vi.mock('../../composables/useKeyboardShortcuts', () => ({ + useKeyboardShortcuts: vi.fn(), +})) + +vi.mock('../../composables/useBoardRealtime', () => ({ + createBoardRealtimeController: vi.fn(() => realtimeMock), +})) + +async function waitForUi() { + await Promise.resolve() + await Promise.resolve() +} + +function makeBoard(overrides: Partial = {}) { + return { + id: 'board-1', + name: 'Test Board', + description: 'A test board', + columns: [ + { + id: 'column-1', + boardId: 'board-1', + name: 'Todo', + position: 0, + wipLimit: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 'column-2', + boardId: 'board-1', + name: 'In Progress', + position: 1, + wipLimit: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } +} + +function mountView() { + return mount(BoardView, { + attachTo: document.body, + global: { + stubs: { + ColumnLane: { + props: ['column'], + template: '
{{ column.name }}
', + }, + BoardSettingsModal: { template: '
' }, + LabelManagerModal: { template: '
' }, + StarterPackCatalogModal: { template: '
' }, + KeyboardShortcutsHelp: { template: '
' }, + FilterPanel: { template: '
' }, + CaptureModal: { template: '
' }, + }, + }, + }) +} + +describe('BoardView — loading and error states', () => { + beforeEach(() => { + vi.clearAllMocks() + routeMock.params.id = 'board-1' + mockSessionStore.userId = 'user-abc' + mockSessionStore.username = 'alice' + mockBoardStore.currentBoard = null + mockBoardStore.cardsByColumn = new Map() + mockBoardStore.loading = false + mockBoardStore.error = null + }) + + it('shows loading spinner when board is loading and not yet available', async () => { + mockBoardStore.loading = true + mockBoardStore.currentBoard = null + + const wrapper = mountView() + await waitForUi() + + expect(wrapper.find('[role="status"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Loading board...') + }) + + it('shows error state when board store has an error', async () => { + mockBoardStore.error = 'Board not found' + + const wrapper = mountView() + await waitForUi() + + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Board not found') + }) + + it('does not show loading spinner when board is loaded even if loading flag is still true', async () => { + mockBoardStore.loading = true + mockBoardStore.currentBoard = makeBoard() + + const wrapper = mountView() + await waitForUi() + + // Loading spinner should not appear because board data is available + expect(wrapper.find('[role="status"]').exists()).toBe(false) + }) +}) + +describe('BoardView — column creation flow', () => { + beforeEach(() => { + vi.clearAllMocks() + routeMock.params.id = 'board-1' + mockSessionStore.userId = 'user-abc' + mockSessionStore.username = 'alice' + mockBoardStore.currentBoard = makeBoard({ columns: [] }) + mockBoardStore.cardsByColumn = new Map() + mockBoardStore.loading = false + mockBoardStore.error = null + }) + + it('shows Add card button which opens column form when no columns exist', async () => { + const wrapper = mountView() + await waitForUi() + + const addCard = wrapper.findAll('button').find((node) => node.text().trim() === 'Add card') + expect(addCard).toBeDefined() + await addCard!.trigger('click') + await waitForUi() + + expect(wrapper.find('input[placeholder="Column name"]').exists()).toBe(true) + }) + + it('submits the column form and calls createColumn on the store', async () => { + const wrapper = mountView() + await waitForUi() + + // Click Add card to open column form + const addCard = wrapper.findAll('button').find((node) => node.text().trim() === 'Add card') + await addCard!.trigger('click') + await waitForUi() + + const input = wrapper.get('input[placeholder="Column name"]') + await input.setValue('New Column') + + const createBtn = wrapper.findAll('button').find((node) => node.text().trim() === 'Create') + await createBtn!.trigger('click') + await waitForUi() + + expect(mockBoardStore.createColumn).toHaveBeenCalledWith('board-1', { name: 'New Column' }) + }) + + it('does not create column with empty name', async () => { + const wrapper = mountView() + await waitForUi() + + const addCard = wrapper.findAll('button').find((node) => node.text().trim() === 'Add card') + await addCard!.trigger('click') + await waitForUi() + + const createBtn = wrapper.findAll('button').find((node) => node.text().trim() === 'Create') + await createBtn!.trigger('click') + await waitForUi() + + expect(mockBoardStore.createColumn).not.toHaveBeenCalled() + }) + + it('hides column form when Cancel is clicked', async () => { + const wrapper = mountView() + await waitForUi() + + const addCard = wrapper.findAll('button').find((node) => node.text().trim() === 'Add card') + await addCard!.trigger('click') + await waitForUi() + + expect(wrapper.find('input[placeholder="Column name"]').exists()).toBe(true) + + const cancelBtn = wrapper.findAll('button').find((node) => node.text().trim() === 'Cancel') + await cancelBtn!.trigger('click') + await waitForUi() + + expect(wrapper.find('input[placeholder="Column name"]').exists()).toBe(false) + }) +}) + +describe('BoardView — board toolbar and columns rendering', () => { + beforeEach(() => { + vi.clearAllMocks() + routeMock.params.id = 'board-1' + mockSessionStore.userId = 'user-abc' + mockSessionStore.username = 'alice' + mockBoardStore.currentBoard = makeBoard() + mockBoardStore.cardsByColumn = new Map([ + ['column-1', []], + ['column-2', []], + ]) + mockBoardStore.loading = false + mockBoardStore.error = null + }) + + it('renders columns sorted by position', async () => { + const wrapper = mountView() + await waitForUi() + + const columnElements = wrapper.findAll('[data-column-id]') + expect(columnElements).toHaveLength(2) + expect(columnElements[0].attributes('data-column-id')).toBe('column-1') + expect(columnElements[1].attributes('data-column-id')).toBe('column-2') + }) + + it('calls fetchBoard on mount', async () => { + mountView() + await waitForUi() + + expect(mockBoardStore.fetchBoard).toHaveBeenCalledWith('board-1') + }) + + it('starts realtime connection on mount', async () => { + mountView() + await waitForUi() + + expect(realtimeMock.start).toHaveBeenCalledWith('board-1') + }) + + it('clears presence and stops realtime on unmount', async () => { + const wrapper = mountView() + await waitForUi() + + wrapper.unmount() + + expect(mockBoardStore.setBoardPresenceMembers).toHaveBeenCalledWith([]) + expect(mockBoardStore.setEditingCard).toHaveBeenCalledWith(null) + expect(realtimeMock.stop).toHaveBeenCalled() + }) +}) + +describe('BoardView — help callout', () => { + beforeEach(() => { + vi.clearAllMocks() + routeMock.params.id = 'board-1' + mockSessionStore.userId = 'user-abc' + mockSessionStore.username = 'alice' + mockBoardStore.currentBoard = makeBoard() + mockBoardStore.cardsByColumn = new Map([['column-1', []]]) + mockBoardStore.loading = false + mockBoardStore.error = null + }) + + it('shows the workspace help callout with board topic', async () => { + const wrapper = mountView() + await waitForUi() + + expect(wrapper.text()).toContain('What should happen on a board?') + }) +}) From 4e96603d116d51031a9571f5a6863108be405edb Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 13 Apr 2026 01:24:10 +0100 Subject: [PATCH 4/9] test: add ReviewView coverage for approve, apply, reject, risk badges Adds 10 test cases covering approve action, apply-to-board action with confirmation, reject with reason, error toasts for failures, summary card counts, two-step flow indicator, and risk level badge rendering. Closes part of #716 --- .../tests/views/ReviewView.coverage.spec.ts | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/views/ReviewView.coverage.spec.ts diff --git a/frontend/taskdeck-web/src/tests/views/ReviewView.coverage.spec.ts b/frontend/taskdeck-web/src/tests/views/ReviewView.coverage.spec.ts new file mode 100644 index 00000000..84c01f5f --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/ReviewView.coverage.spec.ts @@ -0,0 +1,434 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import type { Proposal } from '../../types/automation' +import ReviewView from '../../views/ReviewView.vue' + +const mocks = vi.hoisted(() => ({ + getProposals: vi.fn(), + getProposal: vi.fn(), + approveProposal: vi.fn(), + rejectProposal: vi.fn(), + executeProposal: vi.fn(), + getProposalDiff: vi.fn(), + dismissProposals: vi.fn(), + getBoards: vi.fn(), + successToast: vi.fn(), + errorToast: vi.fn(), + infoToast: vi.fn(), + createRequestId: vi.fn(), +})) + +vi.mock('../../api/automationApi', () => ({ + automationApi: { + getProposals: mocks.getProposals, + getProposal: mocks.getProposal, + approveProposal: mocks.approveProposal, + rejectProposal: mocks.rejectProposal, + executeProposal: mocks.executeProposal, + getProposalDiff: mocks.getProposalDiff, + dismissProposals: mocks.dismissProposals, + }, +})) + +vi.mock('../../api/boardsApi', () => ({ + boardsApi: { + getBoards: mocks.getBoards, + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ + success: mocks.successToast, + error: mocks.errorToast, + info: mocks.infoToast, + }), +})) + +vi.mock('../../utils/requestId', () => ({ + createRequestId: mocks.createRequestId, +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (_error: unknown, fallback: string) => ({ message: fallback }), +})) + +function buildProposal(overrides: Partial = {}): Proposal { + const now = new Date().toISOString() + const futureExpiry = new Date(Date.now() + 24 * 60 * 60_000).toISOString() + const base: Proposal = { + id: 'proposal-1', + sourceType: 'Queue', + sourceReferenceId: 'capture-1', + boardId: 'board-1', + requestedByUserId: 'user-1', + status: 'PendingReview', + riskLevel: 'Low', + summary: 'Test proposal', + diffPreview: null, + validationIssues: null, + createdAt: now, + updatedAt: now, + expiresAt: futureExpiry, + decidedAt: null, + decidedByUserId: null, + appliedAt: null, + failureReason: null, + correlationId: 'triage-run-1', + operations: [], + presentation: { + plainSummary: 'Test proposal summary.', + impactSummary: '1 planned change.', + riskCue: 'Low risk.', + sourceCue: 'Created from Inbox.', + operationHeadlines: ['Create card "Test".'], + affectedEntities: [ + { + entityType: 'Card', + entityId: 'card-1', + label: 'Card card-1', + changeCount: 1, + }, + ], + }, + } + + const hasPresentationOverride = 'presentation' in overrides + const merged: Proposal = { + ...base, + ...overrides, + presentation: hasPresentationOverride + ? overrides.presentation + ? { ...base.presentation!, ...overrides.presentation } + : overrides.presentation + : base.presentation, + } + + if (!hasPresentationOverride && overrides.summary && merged.presentation) { + merged.presentation = { + ...merged.presentation, + plainSummary: `${overrides.summary} summary.`, + operationHeadlines: [`Create card "${overrides.summary}".`], + } + } + + return merged +} + +async function mountAt(path: string) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/workspace/review', name: 'workspace-review', component: ReviewView }, + { path: '/workspace/inbox', name: 'workspace-inbox', component: { template: '
' } }, + { path: '/workspace/boards/:id', name: 'workspace-board', component: { template: '
' } }, + ], + }) + + await router.push(path) + await router.isReady() + + const wrapper = mount(ReviewView, { + attachTo: document.body, + global: { plugins: [router] }, + }) + + await Promise.resolve() + await Promise.resolve() + await wrapper.vm.$nextTick() + + mountedWrapper = wrapper + return { wrapper, router } +} + +let mountedWrapper: ReturnType | null = null +let originalPrompt: typeof window.prompt + +describe('ReviewView — approve and apply actions', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + originalPrompt = window.prompt + mocks.getProposals.mockResolvedValue([]) + mocks.getBoards.mockResolvedValue([ + { id: 'board-1', name: 'Engineering Sprint' }, + ]) + mocks.approveProposal.mockResolvedValue(buildProposal({ status: 'Approved' })) + mocks.rejectProposal.mockResolvedValue(buildProposal({ status: 'Rejected' })) + mocks.executeProposal.mockResolvedValue(buildProposal({ status: 'Applied' })) + mocks.getProposalDiff.mockResolvedValue('diff') + mocks.dismissProposals.mockResolvedValue({ dismissed: 1 }) + mocks.createRequestId.mockReturnValue('request-1') + }) + + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = null + window.prompt = originalPrompt + }) + + it('approves a PendingReview proposal and transitions to Approved status', async () => { + mocks.getProposals.mockResolvedValue([ + buildProposal({ + id: 'proposal-to-approve', + status: 'PendingReview', + summary: 'Approve me', + }), + ]) + + const { wrapper } = await mountAt('/workspace/review') + + expect(wrapper.text()).toContain('Review required') + expect(wrapper.text()).toContain('Approve me') + + const card = wrapper.get('#proposal-proposal-to-approve') + const approveBtn = card.findAll('button').find((b) => b.text() === 'Approve for board') + expect(approveBtn).toBeDefined() + await approveBtn!.trigger('click') + await Promise.resolve() + await wrapper.vm.$nextTick() + + expect(mocks.approveProposal).toHaveBeenCalledWith('proposal-to-approve') + expect(mocks.successToast).toHaveBeenCalled() + }) + + it('applies an Approved proposal to the board after confirmation', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + mocks.getProposals.mockResolvedValue([ + buildProposal({ + id: 'proposal-to-apply', + status: 'Approved', + summary: 'Apply me', + sourceReferenceId: 'capture-2', + }), + ]) + + const { wrapper } = await mountAt('/workspace/review') + + expect(wrapper.text()).toContain('Approved, ready to apply') + + const card = wrapper.get('#proposal-proposal-to-apply') + const applyBtn = card.findAll('button').find((b) => b.text() === 'Apply to board') + expect(applyBtn).toBeDefined() + await applyBtn!.trigger('click') + await Promise.resolve() + await wrapper.vm.$nextTick() + + expect(confirmSpy).toHaveBeenCalled() + expect(mocks.executeProposal).toHaveBeenCalledWith('proposal-to-apply', 'request-1') + expect(mocks.successToast).toHaveBeenCalled() + + confirmSpy.mockRestore() + }) + + it('shows error toast when approve fails', async () => { + mocks.getProposals.mockResolvedValue([ + buildProposal({ + id: 'proposal-fail-approve', + status: 'PendingReview', + summary: 'Fail approve', + }), + ]) + mocks.approveProposal.mockRejectedValue(new Error('Approve failed')) + + const { wrapper } = await mountAt('/workspace/review') + + const card = wrapper.get('#proposal-proposal-fail-approve') + const approveBtn = card.findAll('button').find((b) => b.text() === 'Approve for board') + await approveBtn!.trigger('click') + await Promise.resolve() + await wrapper.vm.$nextTick() + + expect(mocks.errorToast).toHaveBeenCalled() + }) + + it('shows error toast when apply fails', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + mocks.getProposals.mockResolvedValue([ + buildProposal({ + id: 'proposal-fail-apply', + status: 'Approved', + summary: 'Fail apply', + sourceReferenceId: 'capture-3', + }), + ]) + mocks.executeProposal.mockRejectedValue(new Error('Apply failed')) + + const { wrapper } = await mountAt('/workspace/review') + + const card = wrapper.get('#proposal-proposal-fail-apply') + const applyBtn = card.findAll('button').find((b) => b.text() === 'Apply to board') + await applyBtn!.trigger('click') + await Promise.resolve() + await wrapper.vm.$nextTick() + + expect(mocks.errorToast).toHaveBeenCalled() + + confirmSpy.mockRestore() + }) + + it('rejects a proposal with a reason and updates the card status', async () => { + mocks.getProposals.mockResolvedValue([ + buildProposal({ + id: 'proposal-to-reject', + status: 'PendingReview', + summary: 'Reject me', + }), + ]) + window.prompt = vi.fn(() => 'Too risky') + + const { wrapper } = await mountAt('/workspace/review') + + const card = wrapper.get('#proposal-proposal-to-reject') + const rejectBtn = card.findAll('button').find((b) => b.text() === 'Reject') + await rejectBtn!.trigger('click') + await Promise.resolve() + await wrapper.vm.$nextTick() + + expect(mocks.rejectProposal).toHaveBeenCalledWith('proposal-to-reject', 'Too risky') + expect(mocks.successToast).toHaveBeenCalled() + }) +}) + +describe('ReviewView — summary cards', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + originalPrompt = window.prompt + mocks.getBoards.mockResolvedValue([ + { id: 'board-1', name: 'Engineering Sprint' }, + ]) + mocks.approveProposal.mockResolvedValue(buildProposal({ status: 'Approved' })) + mocks.executeProposal.mockResolvedValue(buildProposal({ status: 'Applied' })) + mocks.getProposalDiff.mockResolvedValue('diff') + mocks.dismissProposals.mockResolvedValue({ dismissed: 1 }) + mocks.createRequestId.mockReturnValue('request-1') + }) + + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = null + window.prompt = originalPrompt + }) + + it('renders summary cards with pending and ready counts', async () => { + mocks.getProposals.mockResolvedValue([ + buildProposal({ + id: 'proposal-pending-1', + status: 'PendingReview', + summary: 'Pending 1', + }), + buildProposal({ + id: 'proposal-pending-2', + status: 'PendingReview', + summary: 'Pending 2', + sourceReferenceId: 'capture-2', + }), + buildProposal({ + id: 'proposal-approved-1', + status: 'Approved', + summary: 'Approved 1', + sourceReferenceId: 'capture-3', + }), + ]) + + const { wrapper } = await mountAt('/workspace/review') + + const summaryCards = wrapper.findAll('.td-review-summary-card') + const pendingCard = summaryCards.find((c) => c.text().includes('Pending review')) + expect(pendingCard?.text()).toContain('2') + + const readyCard = summaryCards.find((c) => c.text().includes('Ready to execute')) + expect(readyCard?.text()).toContain('1') + }) + + it('shows two-step flow indicator on pending proposals', async () => { + mocks.getProposals.mockResolvedValue([ + buildProposal({ + id: 'proposal-flow', + status: 'PendingReview', + }), + ]) + + const { wrapper } = await mountAt('/workspace/review') + + const flowSteps = wrapper.find('.td-review-card__flow-steps') + expect(flowSteps.exists()).toBe(true) + expect(flowSteps.attributes('role')).toBe('list') + expect(flowSteps.text()).toContain('Approve') + expect(flowSteps.text()).toContain('Apply to board') + }) +}) + +describe('ReviewView — risk level indicators', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + originalPrompt = window.prompt + mocks.getBoards.mockResolvedValue([{ id: 'board-1', name: 'Sprint' }]) + mocks.approveProposal.mockResolvedValue(buildProposal({ status: 'Approved' })) + mocks.createRequestId.mockReturnValue('request-1') + }) + + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = null + window.prompt = originalPrompt + }) + + it('renders Low risk badge from proposal riskLevel', async () => { + mocks.getProposals.mockResolvedValue([ + buildProposal({ + riskLevel: 'Low', + }), + ]) + + const { wrapper } = await mountAt('/workspace/review') + + const riskBadge = wrapper.find('.td-risk-badge') + expect(riskBadge.exists()).toBe(true) + expect(riskBadge.text()).toContain('Low risk') + expect(riskBadge.classes()).toContain('td-risk--low') + }) + + it('renders Medium risk badge from proposal riskLevel', async () => { + mocks.getProposals.mockResolvedValue([ + buildProposal({ + riskLevel: 'Medium', + }), + ]) + + const { wrapper } = await mountAt('/workspace/review') + + const riskBadge = wrapper.find('.td-risk-badge') + expect(riskBadge.exists()).toBe(true) + expect(riskBadge.text()).toContain('Medium risk') + expect(riskBadge.classes()).toContain('td-risk--medium') + }) +}) + +describe('ReviewView — loading and error on proposals fetch', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + originalPrompt = window.prompt + mocks.getBoards.mockResolvedValue([]) + mocks.createRequestId.mockReturnValue('request-1') + }) + + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = null + window.prompt = originalPrompt + }) + + it('shows error toast when proposals loading fails', async () => { + mocks.getProposals.mockRejectedValue(new Error('Network error')) + + await mountAt('/workspace/review') + + expect(mocks.errorToast).toHaveBeenCalled() + }) +}) From 44782bfeebf1c18cd7112fc3d946ee2ca10e3081 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 13 Apr 2026 01:24:16 +0100 Subject: [PATCH 5/9] test: add AutomationChatView coverage for messaging, sessions, LLM health Adds 16 test cases covering message sending (success, empty, error), message display and role labels, session creation (with/without board, error), session list and switching, loading state, and LLM health states (unavailable, verify flow). Closes part of #716 --- .../views/AutomationChatView.coverage.spec.ts | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/views/AutomationChatView.coverage.spec.ts diff --git a/frontend/taskdeck-web/src/tests/views/AutomationChatView.coverage.spec.ts b/frontend/taskdeck-web/src/tests/views/AutomationChatView.coverage.spec.ts new file mode 100644 index 00000000..e018e96d --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/AutomationChatView.coverage.spec.ts @@ -0,0 +1,467 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import AutomationChatView from '../../views/AutomationChatView.vue' + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + +const routerMocks = vi.hoisted(() => ({ + push: vi.fn(), +})) + +const routeMock = vi.hoisted(() => ({ + query: {} as Record, +})) + +const mocks = vi.hoisted(() => ({ + getMySessions: vi.fn(), + getSession: vi.fn(), + getHealth: vi.fn(), + sendMessage: vi.fn(), + createSession: vi.fn(), + getBoards: vi.fn(), + successToast: vi.fn(), + errorToast: vi.fn(), +})) + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: routerMocks.push }), + useRoute: () => routeMock, +})) + +vi.mock('../../api/chatApi', () => ({ + chatApi: { + getMySessions: mocks.getMySessions, + getSession: mocks.getSession, + getHealth: mocks.getHealth, + sendMessage: mocks.sendMessage, + createSession: mocks.createSession, + }, +})) + +vi.mock('../../api/boardsApi', () => ({ + boardsApi: { + getBoards: mocks.getBoards, + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ + success: mocks.successToast, + error: mocks.errorToast, + }), +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (error: unknown, fallback: string) => { + if (typeof error === 'object' && error !== null) { + const typed = error as { message?: unknown } + if (typeof typed.message === 'string' && typed.message.trim().length > 0) { + return { message: typed.message, code: null } + } + } + return { message: fallback, code: null } + }, +})) + +function buildSession(overrides: Record = {}) { + const now = new Date().toISOString() + return { + id: 'session-1', + userId: 'user-1', + boardId: 'board-1', + title: 'Test session', + status: 'Active', + createdAt: now, + updatedAt: now, + recentMessages: [ + { + id: 'message-1', + sessionId: 'session-1', + role: 'User', + content: 'Hello there', + messageType: 'text', + proposalId: null, + tokenUsage: null, + createdAt: now, + }, + { + id: 'message-2', + sessionId: 'session-1', + role: 'Assistant', + content: 'Hello! How can I help?', + messageType: 'text', + proposalId: null, + tokenUsage: 50, + createdAt: now, + }, + ], + ...overrides, + } +} + +async function waitForAsyncUi() { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +function mountView() { + return mount(AutomationChatView, { + global: { + stubs: { + InputAssistField: defineComponent({ + props: { + modelValue: { type: String, required: true }, + placeholder: { type: String, default: '' }, + options: { type: Array, default: () => [] }, + }, + emits: ['update:modelValue', 'select'], + methods: { + emitInput(event: Event) { + const target = event.target as HTMLInputElement + this.$emit('update:modelValue', target.value) + }, + selectFirstOption() { + const firstOption = (this.options as Array<{ value: string }>)[0] + if (!firstOption) return + this.$emit('update:modelValue', firstOption.value) + this.$emit('select', firstOption) + }, + }, + template: ` +
+ + +
+ `, + }), + }, + }, + }) +} + +function findButtonByText(wrapper: ReturnType, text: string) { + return wrapper.findAll('button').find((node) => node.text().trim() === text) +} + +describe('AutomationChatView — message sending flow', () => { + beforeEach(() => { + vi.clearAllMocks() + routeMock.query = {} + const session = buildSession() + mocks.getMySessions.mockResolvedValue([session]) + mocks.getSession.mockResolvedValue(session) + mocks.getHealth.mockResolvedValue({ + isAvailable: true, + providerName: 'Mock', + errorMessage: null, + model: 'mock-default', + isMock: true, + isProbed: false, + verificationStatus: 'unverified', + }) + mocks.getBoards.mockResolvedValue([ + { + id: 'board-1', + name: 'Board One', + description: 'Primary board', + isArchived: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + mocks.createSession.mockResolvedValue({ id: 'session-created' }) + mocks.sendMessage.mockResolvedValue(undefined) + }) + + it('sends a message when Send Message button is clicked', async () => { + const wrapper = mountView() + await waitForAsyncUi() + + const textarea = wrapper.find('textarea') + await textarea.setValue('Create a new card for deployment') + + const sendBtn = findButtonByText(wrapper, 'Send Message') + expect(sendBtn).toBeDefined() + await sendBtn!.trigger('click') + await waitForAsyncUi() + + expect(mocks.sendMessage).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ content: 'Create a new card for deployment' }), + ) + }) + + it('does not send empty messages', async () => { + const wrapper = mountView() + await waitForAsyncUi() + + const sendBtn = findButtonByText(wrapper, 'Send Message') + expect(sendBtn).toBeDefined() + await sendBtn!.trigger('click') + await waitForAsyncUi() + + expect(mocks.sendMessage).not.toHaveBeenCalled() + }) + + it('shows error toast when send message fails', async () => { + mocks.sendMessage.mockRejectedValueOnce(new Error('Send failed')) + + const wrapper = mountView() + await waitForAsyncUi() + + const textarea = wrapper.find('textarea') + await textarea.setValue('some message') + + const sendBtn = findButtonByText(wrapper, 'Send Message') + await sendBtn!.trigger('click') + await waitForAsyncUi() + + expect(mocks.errorToast).toHaveBeenCalled() + }) + + it('displays user and assistant messages in the chat', async () => { + const wrapper = mountView() + await waitForAsyncUi() + + expect(wrapper.text()).toContain('Hello there') + expect(wrapper.text()).toContain('Hello! How can I help?') + }) + + it('renders message role labels for both user and assistant messages', async () => { + const wrapper = mountView() + await waitForAsyncUi() + + const roleLabels = wrapper.findAll('.td-message-role') + const roleTexts = roleLabels.map((r) => r.text()) + + expect(roleTexts).toContain('User') + expect(roleTexts).toContain('Assistant') + }) +}) + +describe('AutomationChatView — session creation', () => { + beforeEach(() => { + vi.clearAllMocks() + routeMock.query = {} + mocks.getMySessions.mockResolvedValue([]) + mocks.getSession.mockResolvedValue(null) + mocks.getHealth.mockResolvedValue({ + isAvailable: true, + providerName: 'Mock', + errorMessage: null, + model: 'mock-default', + isMock: true, + isProbed: false, + verificationStatus: 'unverified', + }) + mocks.getBoards.mockResolvedValue([ + { + id: 'board-1', + name: 'Board One', + description: 'Primary board', + isArchived: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + mocks.createSession.mockResolvedValue({ id: 'session-new' }) + mocks.sendMessage.mockResolvedValue(undefined) + }) + + it('creates a new session with title and board context', async () => { + const wrapper = mountView() + await waitForAsyncUi() + + await wrapper.get('input[placeholder="Session title"]').setValue('My new session') + await wrapper.get('input[placeholder="Board context (optional)"]').setValue('Board One') + + const createBtn = findButtonByText(wrapper, 'Create Session') + expect(createBtn).toBeDefined() + await createBtn!.trigger('click') + await waitForAsyncUi() + + expect(mocks.createSession).toHaveBeenCalledWith({ + title: 'My new session', + boardId: 'board-1', + }) + }) + + it('creates a session without board context when left blank', async () => { + const wrapper = mountView() + await waitForAsyncUi() + + await wrapper.get('input[placeholder="Session title"]').setValue('No board session') + + const createBtn = findButtonByText(wrapper, 'Create Session') + await createBtn!.trigger('click') + await waitForAsyncUi() + + expect(mocks.createSession).toHaveBeenCalledWith({ + title: 'No board session', + boardId: null, + }) + }) + + it('shows error toast when session creation fails', async () => { + mocks.createSession.mockRejectedValueOnce(new Error('Creation failed')) + + const wrapper = mountView() + await waitForAsyncUi() + + await wrapper.get('input[placeholder="Session title"]').setValue('Failing session') + const createBtn = findButtonByText(wrapper, 'Create Session') + await createBtn!.trigger('click') + await waitForAsyncUi() + + expect(mocks.errorToast).toHaveBeenCalled() + }) +}) + +describe('AutomationChatView — session list', () => { + beforeEach(() => { + vi.clearAllMocks() + routeMock.query = {} + const now = new Date().toISOString() + mocks.getMySessions.mockResolvedValue([ + buildSession({ id: 'session-1', title: 'First session' }), + buildSession({ id: 'session-2', title: 'Second session' }), + ]) + mocks.getSession.mockResolvedValue(buildSession({ id: 'session-1', title: 'First session' })) + mocks.getHealth.mockResolvedValue({ + isAvailable: true, + providerName: 'Mock', + errorMessage: null, + model: 'mock-default', + isMock: true, + isProbed: false, + verificationStatus: 'unverified', + }) + mocks.getBoards.mockResolvedValue([ + { + id: 'board-1', + name: 'Board One', + description: 'Primary board', + isArchived: false, + createdAt: now, + updatedAt: now, + }, + ]) + mocks.sendMessage.mockResolvedValue(undefined) + }) + + it('shows the session list with multiple sessions', async () => { + const wrapper = mountView() + await waitForAsyncUi() + + expect(wrapper.text()).toContain('First session') + expect(wrapper.text()).toContain('Second session') + }) + + it('switches session when a different session button is clicked', async () => { + mocks.getSession.mockImplementation(async (id: string) => { + return buildSession({ id, title: id === 'session-1' ? 'First session' : 'Second session' }) + }) + + const wrapper = mountView() + await waitForAsyncUi() + + const sessionBtn = wrapper.findAll('button').find((b) => b.text().includes('Second session')) + expect(sessionBtn).toBeDefined() + await sessionBtn!.trigger('click') + await waitForAsyncUi() + + expect(mocks.getSession).toHaveBeenCalledWith('session-2') + }) + + it('shows sessions loading indicator when sessions are being fetched', async () => { + const deferred = createDeferred() + mocks.getMySessions.mockReturnValue(deferred.promise) + + const wrapper = mountView() + await Promise.resolve() + + // While loading, there should be no session list items, just the loading state + expect(wrapper.text()).toContain('Loading') + }) +}) + +describe('AutomationChatView — LLM health states', () => { + beforeEach(() => { + vi.clearAllMocks() + routeMock.query = {} + mocks.getMySessions.mockResolvedValue([buildSession()]) + mocks.getSession.mockResolvedValue(buildSession()) + mocks.getBoards.mockResolvedValue([]) + mocks.sendMessage.mockResolvedValue(undefined) + }) + + it('shows unavailable state when provider is not available', async () => { + mocks.getHealth.mockResolvedValue({ + isAvailable: false, + providerName: null, + errorMessage: 'No provider configured', + model: null, + isMock: false, + isProbed: false, + verificationStatus: 'unverified', + }) + + const wrapper = mountView() + await waitForAsyncUi() + + expect(wrapper.text()).toContain('Live LLM unavailable') + }) + + it('shows probing banner when verify button is clicked', async () => { + mocks.getHealth + .mockResolvedValueOnce({ + isAvailable: true, + providerName: 'OpenAI', + errorMessage: null, + model: 'gpt-4o-mini', + isMock: false, + isProbed: false, + verificationStatus: 'unverified', + }) + .mockResolvedValueOnce({ + isAvailable: true, + providerName: 'OpenAI', + errorMessage: null, + model: 'gpt-4o-mini', + isMock: false, + isProbed: true, + verificationStatus: 'verified', + }) + + const wrapper = mountView() + await waitForAsyncUi() + + expect(wrapper.text()).toContain('Live LLM configured') + + const verifyBtn = findButtonByText(wrapper, 'Verify LLM') + await verifyBtn!.trigger('click') + await waitForAsyncUi() + + expect(mocks.getHealth).toHaveBeenCalledWith({ probe: true }) + expect(wrapper.text()).toContain('Live LLM verified') + }) +}) From 8df293741a1798f30d1a6003a654e4ec52af2476 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 13 Apr 2026 01:24:22 +0100 Subject: [PATCH 6/9] test: add CardItem coverage for context menu, blocked state, labels, a11y Adds 21 test cases covering move-to column menu (show/hide, column listing, selection, escape close), blocked badge, label rendering with colors, selection state and aria-selected, click/keyboard events, tabindex, role, and description display. Closes part of #716 --- .../components/CardItem.coverage.spec.ts | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts diff --git a/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts b/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts new file mode 100644 index 00000000..21132249 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import CardItem from '../../components/board/CardItem.vue' +import type { Card, Column } from '../../types/board' + +function createCard(overrides: Partial = {}): Card { + const now = new Date().toISOString() + return { + id: 'card-1', + boardId: 'board-1', + columnId: 'column-1', + title: 'Test Card', + description: 'A description', + dueDate: null, + isBlocked: false, + blockReason: null, + position: 0, + labels: [], + createdAt: now, + updatedAt: now, + ...overrides, + } +} + +function createColumns(): Column[] { + const now = new Date().toISOString() + return [ + { id: 'column-1', boardId: 'board-1', name: 'Todo', position: 0, wipLimit: null, createdAt: now, updatedAt: now }, + { id: 'column-2', boardId: 'board-1', name: 'In Progress', position: 1, wipLimit: null, createdAt: now, updatedAt: now }, + { id: 'column-3', boardId: 'board-1', name: 'Done', position: 2, wipLimit: null, createdAt: now, updatedAt: now }, + ] +} + +describe('CardItem — context move menu', () => { + it('shows move-to menu button when multiple columns exist', () => { + const wrapper = mount(CardItem, { + props: { card: createCard(), columns: createColumns() }, + }) + + const moveBtn = wrapper.find('[data-action="card-move-menu-trigger"]') + expect(moveBtn.exists()).toBe(true) + expect(moveBtn.attributes('aria-label')).toBe('Move to column') + expect(moveBtn.attributes('aria-haspopup')).toBe('true') + expect(moveBtn.attributes('aria-expanded')).toBe('false') + }) + + it('hides move-to menu button when only one column exists', () => { + const columns = [createColumns()[0]] + const wrapper = mount(CardItem, { + props: { card: createCard(), columns }, + }) + + expect(wrapper.find('[data-action="card-move-menu-trigger"]').exists()).toBe(false) + }) + + it('hides move-to menu button when columns prop is not provided', () => { + const wrapper = mount(CardItem, { + props: { card: createCard() }, + }) + + expect(wrapper.find('[data-action="card-move-menu-trigger"]').exists()).toBe(false) + }) + + it('toggles the move menu open/closed on click', async () => { + const wrapper = mount(CardItem, { + props: { card: createCard(), columns: createColumns() }, + }) + + const moveBtn = wrapper.find('[data-action="card-move-menu-trigger"]') + + await moveBtn.trigger('click') + expect(wrapper.find('.td-card-move-menu').exists()).toBe(true) + expect(moveBtn.attributes('aria-expanded')).toBe('true') + + await moveBtn.trigger('click') + expect(wrapper.find('.td-card-move-menu').exists()).toBe(false) + expect(moveBtn.attributes('aria-expanded')).toBe('false') + }) + + it('shows all columns in the move menu with current column marked', async () => { + const wrapper = mount(CardItem, { + props: { card: createCard({ columnId: 'column-1' }), columns: createColumns() }, + }) + + await wrapper.find('[data-action="card-move-menu-trigger"]').trigger('click') + + const menu = wrapper.find('.td-card-move-menu') + expect(menu.exists()).toBe(true) + expect(menu.text()).toContain('Move to...') + expect(menu.text()).toContain('Todo') + expect(menu.text()).toContain('In Progress') + expect(menu.text()).toContain('Done') + expect(menu.text()).toContain('(current)') + + // Current column item should be disabled + const currentItem = menu.find('.td-card-move-menu__item--current') + expect(currentItem.exists()).toBe(true) + expect(currentItem.attributes('disabled')).toBeDefined() + }) + + it('emits move-to event with target column when a column is selected', async () => { + const card = createCard({ columnId: 'column-1' }) + const wrapper = mount(CardItem, { + props: { card, columns: createColumns() }, + }) + + await wrapper.find('[data-action="card-move-menu-trigger"]').trigger('click') + + const menuItems = wrapper.findAll('.td-card-move-menu__item') + // Click "In Progress" (second item, not disabled) + const inProgressItem = menuItems.find((item) => item.text().includes('In Progress')) + expect(inProgressItem).toBeDefined() + await inProgressItem!.trigger('click') + + expect(wrapper.emitted('move-to')).toBeTruthy() + expect(wrapper.emitted('move-to')![0]).toEqual([card, 'column-2']) + }) + + it('closes move menu when Escape is pressed', async () => { + const wrapper = mount(CardItem, { + props: { card: createCard(), columns: createColumns() }, + }) + + await wrapper.find('[data-action="card-move-menu-trigger"]').trigger('click') + expect(wrapper.find('.td-card-move-menu').exists()).toBe(true) + + await wrapper.find('.td-card-move-menu').trigger('keydown', { key: 'Escape' }) + expect(wrapper.find('.td-card-move-menu').exists()).toBe(false) + }) + + it('closes move menu after selecting a column', async () => { + const wrapper = mount(CardItem, { + props: { card: createCard(), columns: createColumns() }, + }) + + await wrapper.find('[data-action="card-move-menu-trigger"]').trigger('click') + expect(wrapper.find('.td-card-move-menu').exists()).toBe(true) + + const menuItems = wrapper.findAll('.td-card-move-menu__item') + const doneItem = menuItems.find((item) => item.text().includes('Done')) + await doneItem!.trigger('click') + + expect(wrapper.find('.td-card-move-menu').exists()).toBe(false) + }) +}) + +describe('CardItem — blocked state', () => { + it('shows blocked badge when card is blocked', () => { + const wrapper = mount(CardItem, { + props: { card: createCard({ isBlocked: true, blockReason: 'Waiting for deps' }) }, + }) + + expect(wrapper.find('.td-board-card__badge--blocked').exists()).toBe(true) + expect(wrapper.text()).toContain('Blocked') + }) + + it('does not show blocked badge when card is not blocked', () => { + const wrapper = mount(CardItem, { + props: { card: createCard({ isBlocked: false }) }, + }) + + expect(wrapper.find('.td-board-card__badge--blocked').exists()).toBe(false) + }) +}) + +describe('CardItem — labels', () => { + it('renders card labels with correct color', () => { + const card = createCard({ + labels: [ + { id: 'label-1', name: 'Urgent', colorHex: '#ff0000', boardId: 'board-1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + { id: 'label-2', name: 'Feature', colorHex: '#00ff00', boardId: 'board-1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + ], + }) + const wrapper = mount(CardItem, { props: { card } }) + + const labels = wrapper.findAll('.td-board-card__label') + expect(labels).toHaveLength(2) + expect(labels[0].text()).toBe('Urgent') + expect(labels[0].attributes('style')).toContain('background-color: #ff0000') + expect(labels[1].text()).toBe('Feature') + }) + + it('does not render labels section when card has no labels', () => { + const wrapper = mount(CardItem, { + props: { card: createCard({ labels: [] }) }, + }) + + expect(wrapper.find('.td-board-card__labels').exists()).toBe(false) + }) +}) + +describe('CardItem — selection and click', () => { + it('applies selected class and aria-selected when isSelected is true', () => { + const wrapper = mount(CardItem, { + props: { card: createCard(), isSelected: true }, + }) + + const cardEl = wrapper.find('.td-board-card') + expect(cardEl.classes()).toContain('td-board-card--selected') + expect(cardEl.attributes('aria-selected')).toBe('true') + }) + + it('emits click event when card is clicked', async () => { + const card = createCard() + const wrapper = mount(CardItem, { props: { card } }) + + await wrapper.find('.td-board-card').trigger('click') + + expect(wrapper.emitted('click')).toBeTruthy() + expect(wrapper.emitted('click')![0]).toEqual([card]) + }) + + it('emits click event on Enter key press', async () => { + const card = createCard() + const wrapper = mount(CardItem, { props: { card } }) + + await wrapper.find('.td-board-card').trigger('keydown.enter') + + expect(wrapper.emitted('click')).toBeTruthy() + expect(wrapper.emitted('click')![0]).toEqual([card]) + }) + + it('emits click event on Space key press', async () => { + const card = createCard() + const wrapper = mount(CardItem, { props: { card } }) + + await wrapper.find('.td-board-card').trigger('keydown.space') + + expect(wrapper.emitted('click')).toBeTruthy() + }) + + it('has tabindex for keyboard navigation', () => { + const wrapper = mount(CardItem, { props: { card: createCard() } }) + + expect(wrapper.find('.td-board-card').attributes('tabindex')).toBe('0') + }) + + it('has role="option" for accessibility', () => { + const wrapper = mount(CardItem, { props: { card: createCard() } }) + + expect(wrapper.find('.td-board-card').attributes('role')).toBe('option') + }) +}) + +describe('CardItem — description display', () => { + it('renders description when present', () => { + const wrapper = mount(CardItem, { + props: { card: createCard({ description: 'Important task details' }) }, + }) + + expect(wrapper.find('.td-board-card__description').exists()).toBe(true) + expect(wrapper.text()).toContain('Important task details') + }) + + it('does not render description when empty', () => { + const wrapper = mount(CardItem, { + props: { card: createCard({ description: '' }) }, + }) + + expect(wrapper.find('.td-board-card__description').exists()).toBe(false) + }) + + it('does not render description when null', () => { + const wrapper = mount(CardItem, { + props: { card: createCard({ description: null }) }, + }) + + expect(wrapper.find('.td-board-card__description').exists()).toBe(false) + }) +}) From 964653f3f3f7b7f8622e3510dda9a354f0c77200 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 13 Apr 2026 01:24:28 +0100 Subject: [PATCH 7/9] test: add BoardCanvas coverage for empty state, columns, drag states, a11y Adds 12 test cases covering empty state display, column rendering with card counts, drag state CSS classes (opacity, scale), drag event emissions, and ARIA role/label attributes on column wrappers. Closes part of #716 --- .../components/BoardCanvas.coverage.spec.ts | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/components/BoardCanvas.coverage.spec.ts diff --git a/frontend/taskdeck-web/src/tests/components/BoardCanvas.coverage.spec.ts b/frontend/taskdeck-web/src/tests/components/BoardCanvas.coverage.spec.ts new file mode 100644 index 00000000..01e9bcc5 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/BoardCanvas.coverage.spec.ts @@ -0,0 +1,238 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import BoardCanvas from '../../components/board/BoardCanvas.vue' +import type { Column, Card, Label } from '../../types/board' + +function makeColumn(overrides: Partial = {}): Column { + return { + id: 'col-1', + boardId: 'board-1', + name: 'Todo', + position: 0, + wipLimit: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } +} + +function makeCard(id: string, columnId: string): Card { + return { + id, + boardId: 'board-1', + columnId, + title: `Card ${id}`, + description: '', + dueDate: null, + isBlocked: false, + blockReason: null, + position: 0, + labels: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } +} + +const columnLaneStub = { + props: ['column', 'cards', 'labels', 'boardId', 'allColumns', 'draggedCard', 'selectedCardId'], + template: '
{{ column.name }} ({{ cards.length }} cards)
', +} + +function mountCanvas(props: Partial['$props']> = {}) { + return mount(BoardCanvas, { + props: { + sortedColumns: [], + cardsByColumn: new Map(), + labels: [] as Label[], + boardId: 'board-1', + hasColumns: false, + draggedColumn: null, + dragOverColumnId: null, + draggedCard: null, + selectedCardId: null, + ...props, + }, + global: { + stubs: { ColumnLane: columnLaneStub }, + }, + }) +} + +describe('BoardCanvas — empty state', () => { + it('shows empty state when hasColumns is false', () => { + const wrapper = mountCanvas({ hasColumns: false, sortedColumns: [] }) + + expect(wrapper.find('.td-board-canvas__empty').exists()).toBe(true) + expect(wrapper.text()).toContain('No columns yet') + expect(wrapper.text()).toContain('Click "Add Column" to get started') + }) + + it('does not show empty state when columns exist', () => { + const columns = [makeColumn()] + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + }) + + expect(wrapper.find('.td-board-canvas__empty').exists()).toBe(false) + }) +}) + +describe('BoardCanvas — column rendering', () => { + it('renders one ColumnLane stub per sorted column', () => { + const columns = [ + makeColumn({ id: 'col-1', name: 'Todo', position: 0 }), + makeColumn({ id: 'col-2', name: 'Done', position: 1 }), + ] + + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + }) + + const lanes = wrapper.findAll('.stub-column') + expect(lanes).toHaveLength(2) + expect(lanes[0].text()).toContain('Todo') + expect(lanes[1].text()).toContain('Done') + }) + + it('passes correct cards count to each column lane', () => { + const columns = [ + makeColumn({ id: 'col-1', name: 'Todo', position: 0 }), + makeColumn({ id: 'col-2', name: 'Done', position: 1 }), + ] + const cardsByColumn = new Map([ + ['col-1', [makeCard('c1', 'col-1'), makeCard('c2', 'col-1')]], + ['col-2', [makeCard('c3', 'col-2')]], + ]) + + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + cardsByColumn, + }) + + const lanes = wrapper.findAll('.stub-column') + expect(lanes[0].text()).toContain('2 cards') + expect(lanes[1].text()).toContain('1 cards') + }) + + it('renders empty card list for columns with no cards', () => { + const columns = [makeColumn({ id: 'col-1', name: 'Empty' })] + const cardsByColumn = new Map() + + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + cardsByColumn, + }) + + expect(wrapper.text()).toContain('0 cards') + }) +}) + +describe('BoardCanvas — drag state classes', () => { + it('applies opacity class to dragged column', () => { + const columns = [ + makeColumn({ id: 'col-1', name: 'Dragging' }), + makeColumn({ id: 'col-2', name: 'Other' }), + ] + const draggedColumn = columns[0] + + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + draggedColumn, + }) + + const dndWrappers = wrapper.findAll('[data-column-dnd-id]') + expect(dndWrappers[0].classes()).toContain('opacity-50') + expect(dndWrappers[1].classes()).not.toContain('opacity-50') + }) + + it('applies scale class to drag-over target column', () => { + const columns = [ + makeColumn({ id: 'col-1', name: 'Source' }), + makeColumn({ id: 'col-2', name: 'Target' }), + ] + + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + dragOverColumnId: 'col-2', + }) + + const dndWrappers = wrapper.findAll('[data-column-dnd-id]') + expect(dndWrappers[0].classes()).not.toContain('transform') + expect(dndWrappers[1].classes()).toContain('transform') + expect(dndWrappers[1].classes()).toContain('scale-105') + }) +}) + +describe('BoardCanvas — drag event emissions', () => { + it('emits columnDragOver when dragover event fires on a column wrapper', async () => { + const columns = [makeColumn({ id: 'col-1' })] + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + }) + + const colWrapper = wrapper.find('[data-column-dnd-id="col-1"]') + await colWrapper.trigger('dragover') + + expect(wrapper.emitted('columnDragOver')).toBeTruthy() + }) + + it('emits columnDragLeave when dragleave event fires', async () => { + const columns = [makeColumn({ id: 'col-1' })] + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + }) + + const colWrapper = wrapper.find('[data-column-dnd-id="col-1"]') + await colWrapper.trigger('dragleave') + + expect(wrapper.emitted('columnDragLeave')).toBeTruthy() + }) + + it('emits columnDrop when drop event fires on a column wrapper', async () => { + const columns = [makeColumn({ id: 'col-1' })] + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + }) + + const colWrapper = wrapper.find('[data-column-dnd-id="col-1"]') + await colWrapper.trigger('drop') + + expect(wrapper.emitted('columnDrop')).toBeTruthy() + }) + + it('emits columnDragEnd when dragend event fires', async () => { + const columns = [makeColumn({ id: 'col-1' })] + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + }) + + const colWrapper = wrapper.find('[data-column-dnd-id="col-1"]') + await colWrapper.trigger('dragend') + + expect(wrapper.emitted('columnDragEnd')).toBeTruthy() + }) +}) + +describe('BoardCanvas — accessibility', () => { + it('assigns role="group" and aria-label to each column wrapper', () => { + const columns = [makeColumn({ id: 'col-1', name: 'Todo' })] + const wrapper = mountCanvas({ + hasColumns: true, + sortedColumns: columns, + }) + + const colWrapper = wrapper.find('[data-column-dnd-id="col-1"]') + expect(colWrapper.attributes('role')).toBe('group') + expect(colWrapper.attributes('aria-label')).toBe('Column: Todo') + }) +}) From 0775183597804c54d75b49bc2fb59f4fbde7ede5 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 13 Apr 2026 01:24:33 +0100 Subject: [PATCH 8/9] test: add BoardActionRail coverage for button emissions and trust hint Adds 9 test cases covering all five action button emissions (capture, chat, review, inbox, addCard), review-first trust hint text, data-board-action-rail attribute, and primary button distinction. Closes part of #716 --- .../BoardActionRail.coverage.spec.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/components/BoardActionRail.coverage.spec.ts diff --git a/frontend/taskdeck-web/src/tests/components/BoardActionRail.coverage.spec.ts b/frontend/taskdeck-web/src/tests/components/BoardActionRail.coverage.spec.ts new file mode 100644 index 00000000..7eacd7d1 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/BoardActionRail.coverage.spec.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import BoardActionRail from '../../components/board/BoardActionRail.vue' + +describe('BoardActionRail — button emissions', () => { + it('renders all action buttons', () => { + const wrapper = mount(BoardActionRail) + + expect(wrapper.text()).toContain('Board Actions') + expect(wrapper.text()).toContain('Capture here') + expect(wrapper.text()).toContain('Ask assistant') + expect(wrapper.text()).toContain('Review proposals') + expect(wrapper.text()).toContain('Open Inbox') + expect(wrapper.text()).toContain('Add card') + }) + + it('emits capture when Capture here button is clicked', async () => { + const wrapper = mount(BoardActionRail) + + const btn = wrapper.findAll('button').find((b) => b.text().trim() === 'Capture here') + await btn!.trigger('click') + + expect(wrapper.emitted('capture')).toHaveLength(1) + }) + + it('emits chat when Ask assistant button is clicked', async () => { + const wrapper = mount(BoardActionRail) + + const btn = wrapper.findAll('button').find((b) => b.text().trim() === 'Ask assistant') + await btn!.trigger('click') + + expect(wrapper.emitted('chat')).toHaveLength(1) + }) + + it('emits review when Review proposals button is clicked', async () => { + const wrapper = mount(BoardActionRail) + + const btn = wrapper.findAll('button').find((b) => b.text().trim() === 'Review proposals') + await btn!.trigger('click') + + expect(wrapper.emitted('review')).toHaveLength(1) + }) + + it('emits inbox when Open Inbox button is clicked', async () => { + const wrapper = mount(BoardActionRail) + + const btn = wrapper.findAll('button').find((b) => b.text().trim() === 'Open Inbox') + await btn!.trigger('click') + + expect(wrapper.emitted('inbox')).toHaveLength(1) + }) + + it('emits addCard when Add card button is clicked', async () => { + const wrapper = mount(BoardActionRail) + + const btn = wrapper.findAll('button').find((b) => b.text().trim() === 'Add card') + await btn!.trigger('click') + + expect(wrapper.emitted('addCard')).toHaveLength(1) + }) + + it('shows the review-first trust hint', () => { + const wrapper = mount(BoardActionRail) + + expect(wrapper.text()).toContain('Only approved changes land on this board.') + }) + + it('has the data-board-action-rail attribute for test targeting', () => { + const wrapper = mount(BoardActionRail) + + expect(wrapper.find('[data-board-action-rail]').exists()).toBe(true) + }) + + it('distinguishes Add card button as primary', () => { + const wrapper = mount(BoardActionRail) + + const addCardBtn = wrapper.findAll('button').find((b) => b.text().trim() === 'Add card') + expect(addCardBtn!.classes()).toContain('td-action-rail__btn--primary') + }) +}) From 3387b9baf85b924e539f20123af9f773fd7565f0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 13 Apr 2026 02:00:27 +0100 Subject: [PATCH 9/9] fix: address review findings in frontend coverage tests - Remove unused `vi` import from CardItem.coverage.spec.ts (CI-blocking lint error) - Add afterEach wrapper cleanup to BoardView.coverage.spec.ts (prevents DOM pollution from attachTo: document.body) - Fix createDeferred generic type in AutomationChatView.coverage.spec.ts (ReturnType[] instead of typeof buildSession[]) - Rename misleading test name in ArchiveView.coverage.spec.ts --- .../components/CardItem.coverage.spec.ts | 2 +- .../tests/views/ArchiveView.coverage.spec.ts | 2 +- .../views/AutomationChatView.coverage.spec.ts | 2 +- .../tests/views/BoardView.coverage.spec.ts | 28 +++++++++++++++++-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts b/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts index 21132249..731ed564 100644 --- a/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' import CardItem from '../../components/board/CardItem.vue' import type { Card, Column } from '../../types/board' diff --git a/frontend/taskdeck-web/src/tests/views/ArchiveView.coverage.spec.ts b/frontend/taskdeck-web/src/tests/views/ArchiveView.coverage.spec.ts index 0ced7846..00215734 100644 --- a/frontend/taskdeck-web/src/tests/views/ArchiveView.coverage.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/ArchiveView.coverage.spec.ts @@ -304,7 +304,7 @@ describe('ArchiveView — loading and empty states', () => { expect(restoreButton!.attributes('disabled')).toBeDefined() }) - it('does not cancel board restore when confirm is cancelled', async () => { + it('does not restore board when confirm is cancelled', async () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) mocks.getBoards.mockResolvedValue([ diff --git a/frontend/taskdeck-web/src/tests/views/AutomationChatView.coverage.spec.ts b/frontend/taskdeck-web/src/tests/views/AutomationChatView.coverage.spec.ts index e018e96d..639c6724 100644 --- a/frontend/taskdeck-web/src/tests/views/AutomationChatView.coverage.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/AutomationChatView.coverage.spec.ts @@ -393,7 +393,7 @@ describe('AutomationChatView — session list', () => { }) it('shows sessions loading indicator when sessions are being fetched', async () => { - const deferred = createDeferred() + const deferred = createDeferred[]>() mocks.getMySessions.mockReturnValue(deferred.promise) const wrapper = mountView() diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts index 792e15a5..4553bc8d 100644 --- a/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import { reactive } from 'vue' import BoardView from '../../views/BoardView.vue' @@ -120,8 +120,10 @@ function makeBoard(overrides: Partial = {}) } } +let mountedWrapper: ReturnType | null = null + function mountView() { - return mount(BoardView, { + const wrapper = mount(BoardView, { attachTo: document.body, global: { stubs: { @@ -138,6 +140,8 @@ function mountView() { }, }, }) + mountedWrapper = wrapper + return wrapper } describe('BoardView — loading and error states', () => { @@ -152,6 +156,11 @@ describe('BoardView — loading and error states', () => { mockBoardStore.error = null }) + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = null + }) + it('shows loading spinner when board is loading and not yet available', async () => { mockBoardStore.loading = true mockBoardStore.currentBoard = null @@ -197,6 +206,11 @@ describe('BoardView — column creation flow', () => { mockBoardStore.error = null }) + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = null + }) + it('shows Add card button which opens column form when no columns exist', async () => { const wrapper = mountView() await waitForUi() @@ -276,6 +290,11 @@ describe('BoardView — board toolbar and columns rendering', () => { mockBoardStore.error = null }) + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = null + }) + it('renders columns sorted by position', async () => { const wrapper = mountView() await waitForUi() @@ -324,6 +343,11 @@ describe('BoardView — help callout', () => { mockBoardStore.error = null }) + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = null + }) + it('shows the workspace help callout with board topic', async () => { const wrapper = mountView() await waitForUi()