diff --git a/web/frontend/src/__tests__/kanban.test.ts b/web/frontend/src/__tests__/kanban.test.ts index 22b5b09..75163ac 100644 --- a/web/frontend/src/__tests__/kanban.test.ts +++ b/web/frontend/src/__tests__/kanban.test.ts @@ -208,12 +208,12 @@ describe('KIN-UI-001: канбан — вкладка в навигации', () // 2-3. Переключение и 5 колонок // ───────────────────────────────────────────────────────────── -describe('KIN-UI-001: канбан — 6 колонок', () => { - it('После переключения на канбан отображаются заголовки всех 6 колонок', async () => { +describe('KIN-UI-001: канбан — 5 колонок', () => { + it('После переключения на канбан отображаются заголовки всех 5 колонок', async () => { const wrapper = await mountOnKanban() const text = wrapper.text() - for (const label of ['Pending', 'In Progress', 'Review', 'Revising', 'Blocked', 'Done']) { + for (const label of ['Pending', 'In Progress', 'Review', 'Blocked', 'Done']) { expect(text, `Колонка "${label}" должна быть видна`).toContain(label) } }) @@ -253,28 +253,14 @@ describe('KIN-UI-001: канбан — 6 колонок', () => { const wrapper = await mountOnKanban() const dropZones = wrapper.findAll('[class*="min-h-24"]') - expect(dropZones[4].find('a[href="/task/KIN-004"]').exists()).toBe(true) + 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[5].find('a[href="/task/KIN-005"]').exists()).toBe(true) - }) - - it('Задача со статусом revising попадает в колонку Revising', async () => { - const projectWithRevising = { - ...MOCK_PROJECT, - tasks: [...MOCK_PROJECT.tasks, makeTask('KIN-006', 'revising')], - } - vi.mocked(api.project).mockResolvedValue(projectWithRevising as any) - - const wrapper = await mountOnKanban() - const dropZones = wrapper.findAll('[class*="min-h-24"]') - - // Revising — индекс 3 - expect(dropZones[3].find('a[href="/task/KIN-006"]').exists()).toBe(true) + expect(dropZones[4].find('a[href="/task/KIN-005"]').exists()).toBe(true) }) it('Задачи с нераспознанным статусом (decomposed, cancelled) не попадают в канбан-колонки', async () => { @@ -291,7 +277,7 @@ describe('KIN-UI-001: канбан — 6 колонок', () => { const wrapper = await mountOnKanban() const dropZones = wrapper.findAll('[class*="min-h-24"]') - // 6 drop zones (6 колонок), decomposed и cancelled не должны быть ни в одной + // 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) @@ -682,25 +668,25 @@ describe('KIN-078: канбан — flex layout без ограничений ш expect(style ?? '').not.toContain('min-width') }) - it('Каждая из 6 колонок имеет flex-1 (растягивается), а не фиксированный w-64', async () => { + it('Каждая из 5 колонок имеет flex-1 (растягивается), а не фиксированный w-64', async () => { const wrapper = await mountOnKanban() - // KANBAN_COLUMNS — 6 колонок, все должны иметь flex-1 + // KANBAN_COLUMNS — 5 колонок, все должны иметь flex-1 const allFlex1 = wrapper.findAll('div').filter(d => d.classes().includes('flex-1') && d.classes().includes('flex-col')) - expect(allFlex1.length, '6 колонок с flex-1 flex-col должны быть').toBe(6) + expect(allFlex1.length, '5 колонок с flex-1 flex-col должны быть').toBe(5) for (const col of allFlex1) { expect(col.classes(), 'Колонка не должна иметь фиксированный w-64').not.toContain('w-64') } }) - it('Каждая из 6 колонок имеет min-w-[12rem] (минимальная ширина)', async () => { + it('Каждая из 5 колонок имеет min-w-[12rem] (минимальная ширина)', async () => { const wrapper = await mountOnKanban() const columns = wrapper.findAll('div').filter(d => d.classes().includes('flex-1') && d.classes().includes('flex-col') ) - expect(columns.length).toBe(6) + expect(columns.length).toBe(5) for (const col of columns) { expect(col.classes(), 'Колонка должна иметь min-w-[12rem]').toContain('min-w-[12rem]') diff --git a/web/frontend/src/__tests__/task-tree.test.ts b/web/frontend/src/__tests__/task-tree.test.ts deleted file mode 100644 index 7adb841..0000000 --- a/web/frontend/src/__tests__/task-tree.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -/** - * 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.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) - }) -}) diff --git a/web/frontend/src/locales/en.json b/web/frontend/src/locales/en.json index ce4e34d..a3eab4f 100644 --- a/web/frontend/src/locales/en.json +++ b/web/frontend/src/locales/en.json @@ -223,9 +223,7 @@ "settings_autocommit": "Autocommit", "settings_autocommit_hint": "— git commit after pipeline", "done_date_from": "From", - "done_date_to": "To", - "status_revising": "Revising", - "kanban_revising": "Revising" + "done_date_to": "To" }, "escalation": { "watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}", diff --git a/web/frontend/src/locales/ru.json b/web/frontend/src/locales/ru.json index d85c638..81c72e6 100644 --- a/web/frontend/src/locales/ru.json +++ b/web/frontend/src/locales/ru.json @@ -223,9 +223,7 @@ "settings_autocommit": "Автокоммит", "settings_autocommit_hint": "— git commit после pipeline", "done_date_from": "От", - "done_date_to": "До", - "status_revising": "Доработка", - "kanban_revising": "Доработка" + "done_date_to": "До" }, "escalation": { "watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}", diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 790c2bc..3613496 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -681,71 +681,6 @@ const manualEscalationTasks = computed(() => { ) }) -// Tree helpers -const childrenMap = computed(() => { - const map = new Map() - for (const t of (project.value?.tasks || [])) { - if (t.parent_task_id) { - const arr = map.get(t.parent_task_id) || [] - arr.push(t) - map.set(t.parent_task_id, arr) - } - } - return map -}) - -function taskDepth(task: Task): number { - let depth = 0 - let current = task - const visited = new Set() - while (current.parent_task_id && !visited.has(current.id)) { - visited.add(current.id) - const parent = (project.value?.tasks || []).find(t => t.id === current.parent_task_id) - if (!parent) break - current = parent - depth++ - } - return depth -} - -const expandedTasks = ref(new Set()) - -function toggleExpand(taskId: string) { - const next = new Set(expandedTasks.value) - if (next.has(taskId)) next.delete(taskId) - else next.add(taskId) - expandedTasks.value = next -} - -function hasChildren(taskId: string): boolean { - return (childrenMap.value.get(taskId)?.length || 0) > 0 -} - -const rootFilteredTasks = computed(() => { - const taskIds = new Set((project.value?.tasks || []).map(t => t.id)) - return filteredTasks.value.filter(t => { - if (!t.parent_task_id) return true - return !taskIds.has(t.parent_task_id) - }) -}) - -const flattenedTasks = computed(() => { - const result: Task[] = [] - function addWithChildren(task: Task) { - result.push(task) - if (expandedTasks.value.has(task.id)) { - const children = childrenMap.value.get(task.id) || [] - for (const child of children) { - addWithChildren(child) - } - } - } - for (const t of rootFilteredTasks.value) { - addWithChildren(t) - } - return result -}) - const filteredDecisions = computed(() => { if (!project.value) return [] let decs = project.value.decisions @@ -861,7 +796,6 @@ const KANBAN_COLUMNS = computed(() => [ { status: 'pending', label: t('projectView.kanban_pending'), headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' }, { status: 'in_progress', label: t('projectView.kanban_in_progress'), headerClass: 'text-blue-400', bgClass: 'bg-blue-950/20' }, { status: 'review', label: t('projectView.kanban_review'), headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' }, - { status: 'revising', label: t('projectView.kanban_revising'), headerClass: 'text-orange-400', bgClass: 'bg-orange-950/20' }, { status: 'blocked', label: t('projectView.kanban_blocked'), headerClass: 'text-red-400', bgClass: 'bg-red-950/20' }, { status: 'done', label: t('projectView.kanban_done'), headerClass: 'text-green-400', bgClass: 'bg-green-950/20' }, ]) @@ -1193,20 +1127,13 @@ async function addDecision() {
-
{{ t('projectView.no_tasks') }}
+
{{ t('projectView.no_tasks') }}
-
-
- - {{ t.id }} @@ -1258,7 +1185,6 @@ async function addDecision() {
{{ t.blocked_reason }}
-