kin/web/frontend/src/__tests__/kanban.test.ts

562 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: '<div />' }
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<string, string> = {}
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')
})
})