Compare commits
3 commits
d552b8bd45
...
c64f9b7538
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c64f9b7538 | ||
|
|
2ae1c0bcb7 | ||
|
|
484c9fc800 |
5 changed files with 487 additions and 15 deletions
|
|
@ -208,12 +208,12 @@ describe('KIN-UI-001: канбан — вкладка в навигации', ()
|
||||||
// 2-3. Переключение и 5 колонок
|
// 2-3. Переключение и 5 колонок
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('KIN-UI-001: канбан — 5 колонок', () => {
|
describe('KIN-UI-001: канбан — 6 колонок', () => {
|
||||||
it('После переключения на канбан отображаются заголовки всех 5 колонок', async () => {
|
it('После переключения на канбан отображаются заголовки всех 6 колонок', async () => {
|
||||||
const wrapper = await mountOnKanban()
|
const wrapper = await mountOnKanban()
|
||||||
|
|
||||||
const text = wrapper.text()
|
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)
|
expect(text, `Колонка "${label}" должна быть видна`).toContain(label)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -253,14 +253,28 @@ describe('KIN-UI-001: канбан — 5 колонок', () => {
|
||||||
const wrapper = await mountOnKanban()
|
const wrapper = await mountOnKanban()
|
||||||
|
|
||||||
const dropZones = wrapper.findAll('[class*="min-h-24"]')
|
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 () => {
|
it('Задача KIN-005 (done) находится в колонке Done', async () => {
|
||||||
const wrapper = await mountOnKanban()
|
const wrapper = await mountOnKanban()
|
||||||
|
|
||||||
const dropZones = wrapper.findAll('[class*="min-h-24"]')
|
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 () => {
|
it('Задачи с нераспознанным статусом (decomposed, cancelled) не попадают в канбан-колонки', async () => {
|
||||||
|
|
@ -277,7 +291,7 @@ describe('KIN-UI-001: канбан — 5 колонок', () => {
|
||||||
const wrapper = await mountOnKanban()
|
const wrapper = await mountOnKanban()
|
||||||
const dropZones = wrapper.findAll('[class*="min-h-24"]')
|
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) {
|
for (const zone of dropZones) {
|
||||||
expect(zone.find('a[href="/task/KIN-010"]').exists()).toBe(false)
|
expect(zone.find('a[href="/task/KIN-010"]').exists()).toBe(false)
|
||||||
expect(zone.find('a[href="/task/KIN-011"]').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')
|
expect(style ?? '').not.toContain('min-width')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Каждая из 5 колонок имеет flex-1 (растягивается), а не фиксированный w-64', async () => {
|
it('Каждая из 6 колонок имеет flex-1 (растягивается), а не фиксированный w-64', async () => {
|
||||||
const wrapper = await mountOnKanban()
|
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'))
|
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) {
|
for (const col of allFlex1) {
|
||||||
expect(col.classes(), 'Колонка не должна иметь фиксированный w-64').not.toContain('w-64')
|
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 wrapper = await mountOnKanban()
|
||||||
|
|
||||||
const columns = wrapper.findAll('div').filter(d =>
|
const columns = wrapper.findAll('div').filter(d =>
|
||||||
d.classes().includes('flex-1') && d.classes().includes('flex-col')
|
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) {
|
for (const col of columns) {
|
||||||
expect(col.classes(), 'Колонка должна иметь min-w-[12rem]').toContain('min-w-[12rem]')
|
expect(col.classes(), 'Колонка должна иметь min-w-[12rem]').toContain('min-w-[12rem]')
|
||||||
|
|
|
||||||
380
web/frontend/src/__tests__/task-tree.test.ts
Normal file
380
web/frontend/src/__tests__/task-tree.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -223,7 +223,9 @@
|
||||||
"settings_autocommit": "Autocommit",
|
"settings_autocommit": "Autocommit",
|
||||||
"settings_autocommit_hint": "— git commit after pipeline",
|
"settings_autocommit_hint": "— git commit after pipeline",
|
||||||
"done_date_from": "From",
|
"done_date_from": "From",
|
||||||
"done_date_to": "To"
|
"done_date_to": "To",
|
||||||
|
"status_revising": "Revising",
|
||||||
|
"kanban_revising": "Revising"
|
||||||
},
|
},
|
||||||
"escalation": {
|
"escalation": {
|
||||||
"watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}",
|
"watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}",
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,9 @@
|
||||||
"settings_autocommit": "Автокоммит",
|
"settings_autocommit": "Автокоммит",
|
||||||
"settings_autocommit_hint": "— git commit после pipeline",
|
"settings_autocommit_hint": "— git commit после pipeline",
|
||||||
"done_date_from": "От",
|
"done_date_from": "От",
|
||||||
"done_date_to": "До"
|
"done_date_to": "До",
|
||||||
|
"status_revising": "Доработка",
|
||||||
|
"kanban_revising": "Доработка"
|
||||||
},
|
},
|
||||||
"escalation": {
|
"escalation": {
|
||||||
"watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}",
|
"watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}",
|
||||||
|
|
|
||||||
|
|
@ -681,6 +681,71 @@ const manualEscalationTasks = computed(() => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Tree helpers
|
||||||
|
const childrenMap = computed(() => {
|
||||||
|
const map = new Map<string, Task[]>()
|
||||||
|
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<string>()
|
||||||
|
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<string>())
|
||||||
|
|
||||||
|
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(() => {
|
const filteredDecisions = computed(() => {
|
||||||
if (!project.value) return []
|
if (!project.value) return []
|
||||||
let decs = project.value.decisions
|
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: '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: '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: '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: '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' },
|
{ status: 'done', label: t('projectView.kanban_done'), headerClass: 'text-green-400', bgClass: 'bg-green-950/20' },
|
||||||
])
|
])
|
||||||
|
|
@ -1127,13 +1193,20 @@ async function addDecision() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">{{ t('projectView.no_tasks') }}</div>
|
<div v-if="flattenedTasks.length === 0" class="text-gray-600 text-sm">{{ t('projectView.no_tasks') }}</div>
|
||||||
<div v-else class="space-y-1">
|
<div v-else class="space-y-1">
|
||||||
<router-link v-for="t in filteredTasks" :key="t.id"
|
<div v-for="t in flattenedTasks" :key="t.id" :style="{ paddingLeft: taskDepth(t) * 24 + 'px' }">
|
||||||
|
<router-link
|
||||||
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
|
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
|
||||||
class="flex flex-col gap-0.5 px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
|
class="flex flex-col gap-0.5 px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<button v-if="hasChildren(t.id)" @click.prevent="toggleExpand(t.id)"
|
||||||
|
data-testid="task-toggle-children"
|
||||||
|
class="text-gray-500 hover:text-gray-300 w-4 shrink-0">
|
||||||
|
{{ expandedTasks.has(t.id) ? '▼' : '▶' }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="w-4 shrink-0"></span>
|
||||||
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
|
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
|
||||||
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
|
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
|
||||||
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
|
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
|
||||||
|
|
@ -1187,6 +1260,7 @@ async function addDecision() {
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Phases Tab -->
|
<!-- Phases Tab -->
|
||||||
<div v-if="activeTab === 'phases'">
|
<div v-if="activeTab === 'phases'">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue