diff --git a/tests/test_api.py b/tests/test_api.py index 440236c..e1c92f5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -394,6 +394,13 @@ def test_patch_task_execution_mode_auto_rejected(client): assert r.status_code == 400 +def test_patch_task_execution_mode_review_accepted(client): + """KIN-074: execution_mode='review' принимается (200) — регрессия после фикса frontend.""" + r = client.patch("/api/tasks/P1-001", json={"execution_mode": "review"}) + assert r.status_code == 200 + assert r.json()["execution_mode"] == "review" + + # --------------------------------------------------------------------------- # KIN-022 — blocked_reason: регрессионные тесты # --------------------------------------------------------------------------- diff --git a/web/frontend/src/__tests__/kanban.test.ts b/web/frontend/src/__tests__/kanban.test.ts new file mode 100644 index 0000000..9c613c5 --- /dev/null +++ b/web/frontend/src/__tests__/kanban.test.ts @@ -0,0 +1,562 @@ +/** + * 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') + }) +})