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 }}
-