kin/web/frontend/src/__tests__/task-tree.test.ts
2026-03-19 21:54:36 +02:00

415 lines
17 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-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: '<div />' }
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<typeof makeTask>[]) {
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<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 },
],
})
}
async function mountTasks(tasks: ReturnType<typeof makeTask>[]) {
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)
})
})