kin: KIN-127-frontend_dev

This commit is contained in:
Gros Frumos 2026-03-18 21:14:50 +02:00
parent d552b8bd45
commit 484c9fc800
5 changed files with 487 additions and 15 deletions

View file

@ -0,0 +1,380 @@
/**
* 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.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.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.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 с текстом revising должен присутствовать
const text = wrapper.text()
expect(text).toContain('revising')
})
})
// ─────────────────────────────────────────────────────────────
// 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. Защита от циклических ссылок
// ─────────────────────────────────────────────────────────────
describe('KIN-127: защита от циклических ссылок', () => {
it('Проект с циклическими parent_task_id рендерится без зависания', 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)
})
})