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') + }) +}) 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') + }) +}) 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..731ed564 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts @@ -0,0 +1,270 @@ +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' + +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) + }) +}) 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..00215734 --- /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 restore board 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') + }) +}) 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..639c6724 --- /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') + }) +}) 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..4553bc8d --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts @@ -0,0 +1,357 @@ +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' + +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, + } +} + +let mountedWrapper: ReturnType | null = null + +function mountView() { + const wrapper = 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: '
' }, + }, + }, + }) + mountedWrapper = wrapper + return wrapper +} + +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 + }) + + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = 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 + }) + + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = 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 + }) + + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = 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 + }) + + afterEach(() => { + mountedWrapper?.unmount() + mountedWrapper = 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?') + }) +}) 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) + }) +}) 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() + }) +})