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')
+ })
+})