563 lines
22 KiB
TypeScript
563 lines
22 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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')
|
|||
|
|
})
|
|||
|
|
})
|