415 lines
17 KiB
TypeScript
415 lines
17 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|