From 484c9fc800d7f5e7d168bdbc78770f636534941c Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 21:14:50 +0200 Subject: [PATCH] kin: KIN-127-frontend_dev --- web/frontend/src/__tests__/kanban.test.ts | 36 +- web/frontend/src/__tests__/task-tree.test.ts | 380 +++++++++++++++++++ web/frontend/src/locales/en.json | 4 +- web/frontend/src/locales/ru.json | 4 +- web/frontend/src/views/ProjectView.vue | 78 +++- 5 files changed, 487 insertions(+), 15 deletions(-) create mode 100644 web/frontend/src/__tests__/task-tree.test.ts diff --git a/web/frontend/src/__tests__/kanban.test.ts b/web/frontend/src/__tests__/kanban.test.ts index 75163ac..22b5b09 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: канбан — 5 колонок', () => { - it('После переключения на канбан отображаются заголовки всех 5 колонок', async () => { +describe('KIN-UI-001: канбан — 6 колонок', () => { + it('После переключения на канбан отображаются заголовки всех 6 колонок', async () => { const wrapper = await mountOnKanban() const text = wrapper.text() - for (const label of ['Pending', 'In Progress', 'Review', 'Blocked', 'Done']) { + for (const label of ['Pending', 'In Progress', 'Review', 'Revising', 'Blocked', 'Done']) { expect(text, `Колонка "${label}" должна быть видна`).toContain(label) } }) @@ -253,14 +253,28 @@ describe('KIN-UI-001: канбан — 5 колонок', () => { const wrapper = await mountOnKanban() const dropZones = wrapper.findAll('[class*="min-h-24"]') - expect(dropZones[3].find('a[href="/task/KIN-004"]').exists()).toBe(true) + expect(dropZones[4].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[4].find('a[href="/task/KIN-005"]').exists()).toBe(true) + 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) }) it('Задачи с нераспознанным статусом (decomposed, cancelled) не попадают в канбан-колонки', async () => { @@ -277,7 +291,7 @@ describe('KIN-UI-001: канбан — 5 колонок', () => { const wrapper = await mountOnKanban() const dropZones = wrapper.findAll('[class*="min-h-24"]') - // 5 drop zones (5 колонок), decomposed и cancelled не должны быть ни в одной + // 6 drop zones (6 колонок), 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) @@ -668,25 +682,25 @@ describe('KIN-078: канбан — flex layout без ограничений ш expect(style ?? '').not.toContain('min-width') }) - it('Каждая из 5 колонок имеет flex-1 (растягивается), а не фиксированный w-64', async () => { + it('Каждая из 6 колонок имеет flex-1 (растягивается), а не фиксированный w-64', async () => { const wrapper = await mountOnKanban() - // KANBAN_COLUMNS — 5 колонок, все должны иметь flex-1 + // KANBAN_COLUMNS — 6 колонок, все должны иметь flex-1 const allFlex1 = wrapper.findAll('div').filter(d => d.classes().includes('flex-1') && d.classes().includes('flex-col')) - expect(allFlex1.length, '5 колонок с flex-1 flex-col должны быть').toBe(5) + expect(allFlex1.length, '6 колонок с flex-1 flex-col должны быть').toBe(6) for (const col of allFlex1) { expect(col.classes(), 'Колонка не должна иметь фиксированный w-64').not.toContain('w-64') } }) - it('Каждая из 5 колонок имеет min-w-[12rem] (минимальная ширина)', async () => { + it('Каждая из 6 колонок имеет 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(5) + expect(columns.length).toBe(6) 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 new file mode 100644 index 0000000..7adb841 --- /dev/null +++ b/web/frontend/src/__tests__/task-tree.test.ts @@ -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: '
' } + +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 a3eab4f..ce4e34d 100644 --- a/web/frontend/src/locales/en.json +++ b/web/frontend/src/locales/en.json @@ -223,7 +223,9 @@ "settings_autocommit": "Autocommit", "settings_autocommit_hint": "— git commit after pipeline", "done_date_from": "From", - "done_date_to": "To" + "done_date_to": "To", + "status_revising": "Revising", + "kanban_revising": "Revising" }, "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 81c72e6..d85c638 100644 --- a/web/frontend/src/locales/ru.json +++ b/web/frontend/src/locales/ru.json @@ -223,7 +223,9 @@ "settings_autocommit": "Автокоммит", "settings_autocommit_hint": "— git commit после pipeline", "done_date_from": "От", - "done_date_to": "До" + "done_date_to": "До", + "status_revising": "Доработка", + "kanban_revising": "Доработка" }, "escalation": { "watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}", diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 3613496..790c2bc 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -681,6 +681,71 @@ 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 @@ -796,6 +861,7 @@ 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' }, ]) @@ -1127,13 +1193,20 @@ async function addDecision() {
-
{{ t('projectView.no_tasks') }}
+
{{ t('projectView.no_tasks') }}
- +
+ + {{ t.id }} @@ -1185,6 +1258,7 @@ async function addDecision() {
{{ t.blocked_reason }}
+