/** * KIN-UI-001: Тесты канбан-вида в ProjectView * * Проверяет: * 1. Вкладка 'Kanban' присутствует в навигации (5 вкладок всего) * 2. Переключение на kanban показывает все 5 колонок * 3. Задачи распределены по колонкам согласно статусу * 4. Drag-and-drop вызывает api.patchTask с {status: newStatus} * 5. Polling запускается при наличии in_progress задач на kanban-вкладке * 6. clearInterval вызывается при переключении с вкладки и в onUnmounted * 7. Существующие вкладки работают без регрессий */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' import ProjectView from '../views/ProjectView.vue' // vi.mock поднимается вверх файла, поэтому определяем здесь vi.mock('../api', () => ({ api: { project: vi.fn(), taskFull: vi.fn(), runTask: vi.fn(), auditProject: vi.fn(), createTask: vi.fn(), patchTask: vi.fn(), patchProject: vi.fn(), deployProject: vi.fn(), getPhases: vi.fn(), }, })) import { api } from '../api' const Stub = { template: '
' } function makeTask(id: string, status: string, category: string | null = null) { return { id, project_id: 'KIN', title: `Task ${id}`, status, priority: 5, assigned_role: null, parent_task_id: null, brief: null, spec: null, execution_mode: null, blocked_reason: null, dangerously_skipped: null, category, acceptance_criteria: null, created_at: '2024-01-01', updated_at: '2024-01-01', } } // Проект с задачами во всех 5 канбан-статусах const MOCK_PROJECT = { id: 'KIN', name: 'Kin', path: '/projects/kin', status: 'active', priority: 5, tech_stack: ['python', 'vue'], execution_mode: 'review', autocommit_enabled: 0, obsidian_vault_path: null, deploy_command: null, created_at: '2024-01-01', total_tasks: 5, done_tasks: 1, active_tasks: 1, blocked_tasks: 1, review_tasks: 1, project_type: 'development', ssh_host: null, ssh_user: null, ssh_key_path: null, ssh_proxy_jump: null, description: null, tasks: [ makeTask('KIN-001', 'pending'), makeTask('KIN-002', 'in_progress', 'UI'), makeTask('KIN-003', 'review'), makeTask('KIN-004', 'blocked'), makeTask('KIN-005', 'done'), ], decisions: [], modules: [], } // localStorage mock const localStorageMock = (() => { let store: Record = {} return { getItem: (k: string) => store[k] ?? null, setItem: (k: string, v: string) => { store[k] = v }, removeItem: (k: string) => { delete store[k] }, clear: () => { store = {} }, } })() Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true }) function makeRouter() { return createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: Stub }, { path: '/project/:id', component: ProjectView, props: true }, ], }) } beforeEach(() => { localStorageMock.clear() vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any) vi.mocked(api.getPhases).mockResolvedValue([]) vi.mocked(api.patchTask).mockResolvedValue(makeTask('KIN-001', 'in_progress') as any) }) afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() }) // ───────────────────────────────────────────────────────────── // Вспомогательная функция: находит tab-кнопку по тексту // ───────────────────────────────────────────────────────────── async function mountOnKanban() { const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() const kanbanTab = wrapper.findAll('button').find(b => b.classes().includes('border-b-2') && b.text().includes('Kanban') )! await kanbanTab.trigger('click') await flushPromises() return wrapper } // ───────────────────────────────────────────────────────────── // 1. Вкладка Kanban в навигации // ───────────────────────────────────────────────────────────── describe('KIN-UI-001: канбан — вкладка в навигации', () => { it('Вкладка "Kanban" присутствует в строке вкладок', async () => { const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() const tabButtons = wrapper.findAll('button').filter(b => b.classes().includes('border-b-2')) const kanbanTab = tabButtons.find(b => b.text().includes('Kanban')) expect(kanbanTab?.exists(), 'Вкладка Kanban должна быть в навигации').toBe(true) }) it('Присутствуют все 5 вкладок: Tasks, Phases, Decisions, Modules, Kanban', async () => { const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() const tabTexts = wrapper .findAll('button') .filter(b => b.classes().includes('border-b-2')) .map(b => b.text().toLowerCase()) for (const expected of ['tasks', 'phases', 'decisions', 'modules', 'kanban']) { expect(tabTexts.some(t => t.includes(expected)), `Вкладка "${expected}" должна быть`).toBe(true) } }) it('Вкладка Kanban отображает счётчик задач', async () => { const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() const kanbanTab = wrapper .findAll('button') .find(b => b.classes().includes('border-b-2') && b.text().includes('Kanban'))! // MOCK_PROJECT.tasks.length === 5 expect(kanbanTab.text()).toContain('5') }) }) // ───────────────────────────────────────────────────────────── // 2-3. Переключение и 5 колонок // ───────────────────────────────────────────────────────────── describe('KIN-UI-001: канбан — 5 колонок', () => { it('После переключения на канбан отображаются заголовки всех 5 колонок', async () => { const wrapper = await mountOnKanban() const text = wrapper.text() for (const label of ['Pending', 'In Progress', 'Review', 'Blocked', 'Done']) { expect(text, `Колонка "${label}" должна быть видна`).toContain(label) } }) it('Каждая из 5 задач отображается ровно в одной колонке', async () => { const wrapper = await mountOnKanban() for (const task of MOCK_PROJECT.tasks) { const links = wrapper.findAll(`a[href="/task/${task.id}"]`) expect(links, `Задача ${task.id} должна появляться ровно 1 раз`).toHaveLength(1) } }) it('Задача KIN-001 (pending) находится в колонке Pending', async () => { const wrapper = await mountOnKanban() // Первая колонка — Pending const dropZones = wrapper.findAll('[class*="min-h-24"]') expect(dropZones[0].find('a[href="/task/KIN-001"]').exists()).toBe(true) }) it('Задача KIN-002 (in_progress) находится в колонке In Progress', async () => { const wrapper = await mountOnKanban() const dropZones = wrapper.findAll('[class*="min-h-24"]') expect(dropZones[1].find('a[href="/task/KIN-002"]').exists()).toBe(true) }) it('Задача KIN-003 (review) находится в колонке Review', async () => { const wrapper = await mountOnKanban() const dropZones = wrapper.findAll('[class*="min-h-24"]') expect(dropZones[2].find('a[href="/task/KIN-003"]').exists()).toBe(true) }) it('Задача KIN-004 (blocked) находится в колонке Blocked', async () => { const wrapper = await mountOnKanban() const dropZones = wrapper.findAll('[class*="min-h-24"]') expect(dropZones[3].find('a[href="/task/KIN-004"]').exists()).toBe(true) }) it('Задача KIN-005 (done) находится в колонке Done', async () => { const wrapper = await mountOnKanban() const dropZones = wrapper.findAll('[class*="min-h-24"]') expect(dropZones[4].find('a[href="/task/KIN-005"]').exists()).toBe(true) }) it('Задачи с нераспознанным статусом (decomposed, cancelled) не попадают в канбан-колонки', async () => { const projectWithExtra = { ...MOCK_PROJECT, tasks: [ ...MOCK_PROJECT.tasks, makeTask('KIN-010', 'decomposed'), makeTask('KIN-011', 'cancelled'), ], } vi.mocked(api.project).mockResolvedValue(projectWithExtra as any) const wrapper = await mountOnKanban() const dropZones = wrapper.findAll('[class*="min-h-24"]') // 5 drop zones (5 колонок), decomposed и cancelled не должны быть ни в одной for (const zone of dropZones) { expect(zone.find('a[href="/task/KIN-010"]').exists()).toBe(false) expect(zone.find('a[href="/task/KIN-011"]').exists()).toBe(false) } }) }) // ───────────────────────────────────────────────────────────── // 4. Смена статуса через drag-and-drop // ───────────────────────────────────────────────────────────── describe('KIN-UI-001: канбан — смена статуса через drag-and-drop', () => { it('Drag-and-drop вызывает api.patchTask с {status: новый_статус}', async () => { vi.mocked(api.patchTask).mockResolvedValue(makeTask('KIN-001', 'in_progress') as any) const wrapper = await mountOnKanban() // Находим карточку KIN-001 в колонке pending и начинаем перетаскивание const taskCard = wrapper.find('a[href="/task/KIN-001"]') expect(taskCard.exists(), 'Карточка KIN-001 должна быть в DOM').toBe(true) await taskCard.trigger('dragstart') // Роняем в колонку in_progress (индекс 1) const dropZones = wrapper.findAll('[class*="min-h-24"]') await dropZones[1].trigger('drop') await flushPromises() expect(vi.mocked(api.patchTask)).toHaveBeenCalledWith('KIN-001', { status: 'in_progress' }) }) it('Drop в ту же колонку не вызывает patchTask', async () => { const wrapper = await mountOnKanban() // KIN-001 уже в pending (индекс 0), роняем обратно в pending const taskCard = wrapper.find('a[href="/task/KIN-001"]') await taskCard.trigger('dragstart') const dropZones = wrapper.findAll('[class*="min-h-24"]') await dropZones[0].trigger('drop') // same status = pending await flushPromises() expect(vi.mocked(api.patchTask)).not.toHaveBeenCalled() }) it('После успешного drop задача перемещается в новую колонку (optimistic update)', async () => { const updatedTask = makeTask('KIN-001', 'review') vi.mocked(api.patchTask).mockResolvedValue(updatedTask as any) const wrapper = await mountOnKanban() const taskCard = wrapper.find('a[href="/task/KIN-001"]') await taskCard.trigger('dragstart') const dropZones = wrapper.findAll('[class*="min-h-24"]') await dropZones[2].trigger('drop') // review = индекс 2 await flushPromises() // KIN-001 должен теперь быть в колонке review (индекс 2) expect(dropZones[2].find('a[href="/task/KIN-001"]').exists()).toBe(true) }) }) // ───────────────────────────────────────────────────────────── // 5-6. Polling и clearInterval // ───────────────────────────────────────────────────────────── describe('KIN-UI-001: канбан — polling', () => { it('5. Polling запускается на канбан-вкладке при наличии in_progress задач (повторный вызов api.project через 5с)', async () => { vi.useFakeTimers() vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any) const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() const callsOnMount = vi.mocked(api.project).mock.calls.length // Переключаемся на kanban — есть KIN-002 in_progress → запускает setInterval const kanbanTab = wrapper.findAll('button').find(b => b.classes().includes('border-b-2') && b.text().includes('Kanban') )! await kanbanTab.trigger('click') await flushPromises() // Продвигаем время на 5с → polling-интервал срабатывает await vi.advanceTimersByTimeAsync(5000) await flushPromises() expect(vi.mocked(api.project).mock.calls.length, 'api.project должен вызваться повторно').toBeGreaterThan(callsOnMount) }) it('Polling не запускается на канбан-вкладке если нет in_progress задач', async () => { vi.useFakeTimers() const projectNoPending = { ...MOCK_PROJECT, tasks: MOCK_PROJECT.tasks.filter(t => t.status !== 'in_progress'), } vi.mocked(api.project).mockResolvedValue(projectNoPending as any) const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() const callsOnMount = vi.mocked(api.project).mock.calls.length const kanbanTab = wrapper.findAll('button').find(b => b.classes().includes('border-b-2') && b.text().includes('Kanban') )! await kanbanTab.trigger('click') await flushPromises() await vi.advanceTimersByTimeAsync(5000) await flushPromises() expect(vi.mocked(api.project).mock.calls.length, 'api.project не должен вызываться без in_progress').toBe(callsOnMount) }) it('6. Polling останавливается при переключении с канбан-вкладки на другую', async () => { vi.useFakeTimers() vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any) const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() // Запускаем polling const kanbanTab = wrapper.findAll('button').find(b => b.classes().includes('border-b-2') && b.text().includes('Kanban') )! await kanbanTab.trigger('click') await flushPromises() // Первый тик polling await vi.advanceTimersByTimeAsync(5000) await flushPromises() const callsWhilePolling = vi.mocked(api.project).mock.calls.length // Переключаемся на Tasks → clearInterval должен быть вызван const tasksTab = wrapper.findAll('button').find(b => b.classes().includes('border-b-2') && b.text().includes('Tasks') )! await tasksTab.trigger('click') await flushPromises() // Ещё 5с — polling остановлен, новых вызовов быть не должно await vi.advanceTimersByTimeAsync(5000) await flushPromises() expect(vi.mocked(api.project).mock.calls.length, 'После переключения вкладки polling должен остановиться').toBe(callsWhilePolling) }) it('6б. clearInterval вызывается в onUnmounted — polling не продолжается после размонтирования', async () => { vi.useFakeTimers() vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any) const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() // Запускаем polling const kanbanTab = wrapper.findAll('button').find(b => b.classes().includes('border-b-2') && b.text().includes('Kanban') )! await kanbanTab.trigger('click') await flushPromises() await vi.advanceTimersByTimeAsync(5000) await flushPromises() const callsBeforeUnmount = vi.mocked(api.project).mock.calls.length // Размонтируем компонент — должен вызвать clearInterval wrapper.unmount() // Ещё 5с — polling должен быть остановлен await vi.advanceTimersByTimeAsync(5000) expect(vi.mocked(api.project).mock.calls.length, 'После unmount polling должен остановиться').toBe(callsBeforeUnmount) }) }) // ───────────────────────────────────────────────────────────── // 7. Регрессии: другие вкладки работают нормально // ───────────────────────────────────────────────────────────── describe('KIN-UI-001: регрессии — другие вкладки не сломаны', () => { it('Вкладка Tasks по умолчанию показывает список задач', async () => { const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() // Должны быть ссылки на задачи const taskLinks = wrapper.findAll('a[href^="/task/"]') expect(taskLinks.length).toBeGreaterThan(0) }) it('Переключение tasks→kanban→tasks не теряет список задач', async () => { const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() const taskLinksInitial = wrapper.findAll('a[href^="/task/"]').length // Переключаемся на kanban const kanbanTab = wrapper.findAll('button').find(b => b.classes().includes('border-b-2') && b.text().includes('Kanban') )! await kanbanTab.trigger('click') await flushPromises() // Переключаемся обратно на tasks const tasksTab = wrapper.findAll('button').find(b => b.classes().includes('border-b-2') && b.text().includes('Tasks') )! await tasksTab.trigger('click') await flushPromises() const taskLinksAfter = wrapper.findAll('a[href^="/task/"]').length expect(taskLinksAfter).toBe(taskLinksInitial) }) it('Вкладка Decisions переключается и отображается без ошибок', async () => { const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() const decisionsTab = wrapper.findAll('button').find(b => b.classes().includes('border-b-2') && b.text().toLowerCase().includes('decisions') )! await decisionsTab.trigger('click') await flushPromises() expect(wrapper.text()).toContain('No decisions') }) it('Вкладка Modules переключается и отображается без ошибок', async () => { const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() const modulesTab = wrapper.findAll('button').find(b => b.classes().includes('border-b-2') && b.text().toLowerCase().includes('modules') )! await modulesTab.trigger('click') await flushPromises() expect(wrapper.text()).toContain('No modules') }) })