/** * KIN-127: Тесты древовидного отображения задач в ProjectView * * Проверяет: * 1. Кнопка-треугольник показывается только для задач с дочерними * 2. Нажатие на треугольник раскрывает дочерние задачи * 3. Повторное нажатие скрывает дочерние задачи * 4. Дочерние задачи имеют отступ (paddingLeft) пропорциональный глубине * 5. Корневые задачи без дочерних не имеют треугольника * 6. Статус revising корректно отображается в списке задач * 7. i18n ключи status_revising и kanban_revising присутствуют в локалях */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' import ProjectView from '../views/ProjectView.vue' 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 = 'pending', parentId: string | null = null, ) { return { id, project_id: 'KIN', title: `Task ${id}`, status, priority: 5, assigned_role: null, parent_task_id: parentId, brief: null, spec: null, execution_mode: null, blocked_reason: null, dangerously_skipped: null, category: null, acceptance_criteria: null, created_at: '2024-01-01', updated_at: '2024-01-01', } } function makeProject(tasks: ReturnType[]) { return { 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: tasks.length, done_tasks: 0, active_tasks: 1, blocked_tasks: 0, review_tasks: 0, project_type: 'development', ssh_host: null, ssh_user: null, ssh_key_path: null, ssh_proxy_jump: null, description: null, tasks, decisions: [], modules: [], } } 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 }, ], }) } async function mountTasks(tasks: ReturnType[]) { vi.mocked(api.project).mockResolvedValue(makeProject(tasks) as any) vi.mocked(api.getPhases).mockResolvedValue([]) const router = makeRouter() await router.push('/project/KIN') const wrapper = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() return wrapper } beforeEach(() => { localStorageMock.clear() vi.clearAllMocks() }) // ───────────────────────────────────────────────────────────── // 1. Треугольник-кнопка toggle // ───────────────────────────────────────────────────────────── describe('KIN-127: дерево задач — кнопка toggle', () => { it('Кнопка toggle (data-testid=task-toggle-children) показывается для задачи с дочерними', async () => { const tasks = [ makeTask('KIN-001', 'pending', null), makeTask('KIN-002', 'pending', 'KIN-001'), ] const wrapper = await mountTasks(tasks) const toggleBtns = wrapper.findAll('[data-testid="task-toggle-children"]') expect(toggleBtns.length, 'Кнопка toggle должна быть для KIN-001').toBe(1) }) it('Кнопка toggle НЕ показывается для листовой задачи (без дочерних)', async () => { const tasks = [ makeTask('KIN-001', 'pending', null), makeTask('KIN-002', 'pending', null), ] const wrapper = await mountTasks(tasks) const toggleBtns = wrapper.findAll('[data-testid="task-toggle-children"]') expect(toggleBtns.length, 'Toggle кнопок не должно быть для листовых задач').toBe(0) }) it('Кнопка toggle НЕ показывается для дочерней задачи без своих детей', async () => { const tasks = [ makeTask('KIN-001', 'pending', null), makeTask('KIN-002', 'pending', 'KIN-001'), ] const wrapper = await mountTasks(tasks) // KIN-001 — раскрываем const toggleBtn = wrapper.find('[data-testid="task-toggle-children"]') await toggleBtn.trigger('click') await flushPromises() // После раскрытия KIN-002 появляется, но у него нет toggle const allToggles = wrapper.findAll('[data-testid="task-toggle-children"]') expect(allToggles.length, 'Только KIN-001 имеет toggle').toBe(1) }) }) // ───────────────────────────────────────────────────────────── // 2-3. Раскрытие и скрытие дочерних задач // ───────────────────────────────────────────────────────────── describe('KIN-127: дерево задач — раскрытие/скрытие', () => { it('До нажатия на toggle дочерние задачи не видны', async () => { const tasks = [ makeTask('KIN-001', 'pending', null), makeTask('KIN-002', 'pending', 'KIN-001'), ] const wrapper = await mountTasks(tasks) // KIN-002 не должен быть виден (свёрнут по умолчанию) expect(wrapper.find('a[href="/task/KIN-002"]').exists()).toBe(false) }) it('После нажатия на toggle дочерняя задача появляется', async () => { const tasks = [ makeTask('KIN-001', 'pending', null), makeTask('KIN-002', 'pending', 'KIN-001'), ] const wrapper = await mountTasks(tasks) const toggleBtn = wrapper.find('[data-testid="task-toggle-children"]') await toggleBtn.trigger('click') await flushPromises() expect(wrapper.find('a[href="/task/KIN-002"]').exists()).toBe(true) }) it('Повторное нажатие на toggle скрывает дочерние задачи', async () => { const tasks = [ makeTask('KIN-001', 'pending', null), makeTask('KIN-002', 'pending', 'KIN-001'), ] const wrapper = await mountTasks(tasks) const toggleBtn = wrapper.find('[data-testid="task-toggle-children"]') // Раскрываем await toggleBtn.trigger('click') await flushPromises() expect(wrapper.find('a[href="/task/KIN-002"]').exists()).toBe(true) // Сворачиваем await toggleBtn.trigger('click') await flushPromises() expect(wrapper.find('a[href="/task/KIN-002"]').exists()).toBe(false) }) it('Иконка переключается ▶ → ▼ при раскрытии', async () => { const tasks = [ makeTask('KIN-001', 'pending', null), makeTask('KIN-002', 'pending', 'KIN-001'), ] const wrapper = await mountTasks(tasks) const toggleBtn = wrapper.find('[data-testid="task-toggle-children"]') expect(toggleBtn.text()).toContain('▶') await toggleBtn.trigger('click') await flushPromises() expect(toggleBtn.text()).toContain('▼') }) }) // ───────────────────────────────────────────────────────────── // 4. Отступы по глубине // ───────────────────────────────────────────────────────────── describe('KIN-127: дерево задач — отступы', () => { it('Корневая задача имеет paddingLeft 0px', async () => { const tasks = [makeTask('KIN-001', 'pending', null)] const wrapper = await mountTasks(tasks) // Обёртка корневой задачи const taskWrapper = wrapper.find('div[style*="padding-left"]') if (taskWrapper.exists()) { expect((taskWrapper.element as HTMLElement).style.paddingLeft).toBe('0px') } else { // Если стиль не задан явно для 0 — это тоже приемлемо expect(true).toBe(true) } }) it('Дочерняя задача первого уровня имеет paddingLeft 24px', async () => { const tasks = [ makeTask('KIN-001', 'pending', null), makeTask('KIN-002', 'pending', 'KIN-001'), ] const wrapper = await mountTasks(tasks) // Раскрываем KIN-001 const toggleBtn = wrapper.find('[data-testid="task-toggle-children"]') await toggleBtn.trigger('click') await flushPromises() // Находим обёртку KIN-002 — она должна иметь paddingLeft: 24px const allWrappers = wrapper.findAll('div[style*="padding-left"]') const child1Wrapper = allWrappers.find(w => w.find('a[href="/task/KIN-002"]').exists() ) expect(child1Wrapper?.exists()).toBe(true) expect((child1Wrapper?.element as HTMLElement | undefined)?.style.paddingLeft).toBe('24px') }) it('Задача второго уровня имеет paddingLeft 48px', async () => { const tasks = [ makeTask('KIN-001', 'pending', null), makeTask('KIN-002', 'pending', 'KIN-001'), makeTask('KIN-003', 'pending', 'KIN-002'), ] const wrapper = await mountTasks(tasks) // Раскрываем оба уровня const toggle1 = wrapper.find('[data-testid="task-toggle-children"]') await toggle1.trigger('click') await flushPromises() const toggles = wrapper.findAll('[data-testid="task-toggle-children"]') // Второй toggle — для KIN-002 await toggles[1].trigger('click') await flushPromises() const allWrappers = wrapper.findAll('div[style*="padding-left"]') const child2Wrapper = allWrappers.find(w => w.find('a[href="/task/KIN-003"]').exists() ) expect(child2Wrapper?.exists()).toBe(true) expect((child2Wrapper?.element as HTMLElement | undefined)?.style.paddingLeft).toBe('48px') }) }) // ───────────────────────────────────────────────────────────── // 5. Статус revising // ───────────────────────────────────────────────────────────── describe('KIN-127: статус revising', () => { it('Задача со статусом revising отображается в списке задач', async () => { const tasks = [makeTask('KIN-001', 'revising', null)] const wrapper = await mountTasks(tasks) expect(wrapper.find('a[href="/task/KIN-001"]').exists()).toBe(true) }) it('Badge для статуса revising отображается с orange цветом', async () => { const tasks = [makeTask('KIN-001', 'revising', null)] const wrapper = await mountTasks(tasks) // Badge получает raw status string (decision #827: i18n на стороне вызывающего) expect(wrapper.text()).toContain('revising') // Badge с color="orange" применяет класс text-orange-400 (Badge.vue: colors.orange) const orangeBadge = wrapper.find('.text-orange-400') expect(orangeBadge.exists()).toBe(true) }) }) // ───────────────────────────────────────────────────────────── // 6. i18n ключи // ───────────────────────────────────────────────────────────── describe('KIN-127: i18n ключи', () => { it('en.json содержит ключ status_revising', async () => { const en = await import('../locales/en.json') expect((en as any).projectView.status_revising).toBeDefined() expect((en as any).projectView.status_revising).toBe('Revising') }) it('en.json содержит ключ kanban_revising', async () => { const en = await import('../locales/en.json') expect((en as any).projectView.kanban_revising).toBeDefined() expect((en as any).projectView.kanban_revising).toBe('Revising') }) it('ru.json содержит ключ status_revising', async () => { const ru = await import('../locales/ru.json') expect((ru as any).projectView.status_revising).toBeDefined() expect((ru as any).projectView.status_revising).toBe('Доработка') }) it('ru.json содержит ключ kanban_revising', async () => { const ru = await import('../locales/ru.json') expect((ru as any).projectView.kanban_revising).toBeDefined() expect((ru as any).projectView.kanban_revising).toBe('Доработка') }) }) // ───────────────────────────────────────────────────────────── // 7. Защита от циклических ссылок // ───────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────── // 8. KIN-021: visitedInFlatten в addWithChildren — регрессия // ───────────────────────────────────────────────────────────── describe('KIN-021: visitedInFlatten — flattenedTasks с взаимным циклом', () => { it('flattenedTasks при A→parent:B, B→parent:A рендерит непустой HTML проекта без зависания', async () => { // Взаимный цикл: CYCLE-A → parent CYCLE-B, CYCLE-B → parent CYCLE-A const tasks = [ makeTask('CYCLE-A', 'pending', 'CYCLE-B'), makeTask('CYCLE-B', 'pending', 'CYCLE-A'), ] const wrapper = await mountTasks(tasks) // Главное: компонент рендерит непустой HTML — нет бесконечной рекурсии / stack overflow // visitedInFlatten Set предотвращает зависание при вызове addWithChildren expect(wrapper.html().length).toBeGreaterThan(100) // Оба task взаимно исключают друг друга из rootFilteredTasks: // parent каждого указывает на другой task из того же проекта // → в flattenedTasks не попадают → ссылок в DOM нет expect(wrapper.find('a[href="/task/CYCLE-A"]').exists()).toBe(false) expect(wrapper.find('a[href="/task/CYCLE-B"]').exists()).toBe(false) }) }) describe('KIN-127: защита от циклических ссылок', () => { it('Проект с циклическими parent_task_id рендерится без зависания и не показывает toggle', async () => { // Специально создаём циклическую ссылку: KIN-001 -> KIN-002 -> KIN-001 const tasks = [ { ...makeTask('KIN-001', 'pending', 'KIN-002') }, { ...makeTask('KIN-002', 'pending', 'KIN-001') }, ] // Не должен зависнуть — задачи просто отобразятся как корневые const wrapper = await mountTasks(tasks) // Рендер завершился без ошибок expect(wrapper.exists()).toBe(true) // rootFilteredTasks пустой: оба KIN-001 и KIN-002 имеют parent_task_id // указывающий на существующую задачу → оба отфильтрованы (decision #817) const taskLinks = wrapper.findAll('a[href^="/task/"]') expect(taskLinks.length).toBe(0) // toggle кнопок нет — задачи не попали в список (decision #826: независимые visited Set) const toggleBtns = wrapper.findAll('[data-testid="task-toggle-children"]') expect(toggleBtns.length).toBe(0) }) })