2026-03-18 21:14:50 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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()) {
|
2026-03-19 21:54:36 +02:00
|
|
|
|
expect((taskWrapper.element as HTMLElement).style.paddingLeft).toBe('0px')
|
2026-03-18 21:14:50 +02:00
|
|
|
|
} 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)
|
2026-03-19 21:54:36 +02:00
|
|
|
|
expect((child1Wrapper?.element as HTMLElement | undefined)?.style.paddingLeft).toBe('24px')
|
2026-03-18 21:14:50 +02:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-03-19 21:54:36 +02:00
|
|
|
|
expect((child2Wrapper?.element as HTMLElement | undefined)?.style.paddingLeft).toBe('48px')
|
2026-03-18 21:14:50 +02:00
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-18 21:47:49 +02:00
|
|
|
|
it('Badge для статуса revising отображается с orange цветом', async () => {
|
2026-03-18 21:14:50 +02:00
|
|
|
|
const tasks = [makeTask('KIN-001', 'revising', null)]
|
|
|
|
|
|
const wrapper = await mountTasks(tasks)
|
|
|
|
|
|
|
2026-03-18 21:47:49 +02:00
|
|
|
|
// Badge получает raw status string (decision #827: i18n на стороне вызывающего)
|
|
|
|
|
|
expect(wrapper.text()).toContain('revising')
|
|
|
|
|
|
// Badge с color="orange" применяет класс text-orange-400 (Badge.vue: colors.orange)
|
|
|
|
|
|
const orangeBadge = wrapper.find('.text-orange-400')
|
2026-03-18 21:26:40 +02:00
|
|
|
|
expect(orangeBadge.exists()).toBe(true)
|
2026-03-18 21:14:50 +02:00
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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. Защита от циклических ссылок
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-18 21:51:19 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 8. KIN-021: visitedInFlatten в addWithChildren — регрессия
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe('KIN-021: visitedInFlatten — flattenedTasks с взаимным циклом', () => {
|
|
|
|
|
|
it('flattenedTasks при A→parent:B, B→parent:A рендерит непустой HTML проекта без зависания', async () => {
|
|
|
|
|
|
// Взаимный цикл: CYCLE-A → parent CYCLE-B, CYCLE-B → parent CYCLE-A
|
|
|
|
|
|
const tasks = [
|
|
|
|
|
|
makeTask('CYCLE-A', 'pending', 'CYCLE-B'),
|
|
|
|
|
|
makeTask('CYCLE-B', 'pending', 'CYCLE-A'),
|
|
|
|
|
|
]
|
|
|
|
|
|
const wrapper = await mountTasks(tasks)
|
|
|
|
|
|
|
|
|
|
|
|
// Главное: компонент рендерит непустой HTML — нет бесконечной рекурсии / stack overflow
|
|
|
|
|
|
// visitedInFlatten Set предотвращает зависание при вызове addWithChildren
|
|
|
|
|
|
expect(wrapper.html().length).toBeGreaterThan(100)
|
|
|
|
|
|
// Оба task взаимно исключают друг друга из rootFilteredTasks:
|
|
|
|
|
|
// parent каждого указывает на другой task из того же проекта
|
|
|
|
|
|
// → в flattenedTasks не попадают → ссылок в DOM нет
|
|
|
|
|
|
expect(wrapper.find('a[href="/task/CYCLE-A"]').exists()).toBe(false)
|
|
|
|
|
|
expect(wrapper.find('a[href="/task/CYCLE-B"]').exists()).toBe(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 21:14:50 +02:00
|
|
|
|
describe('KIN-127: защита от циклических ссылок', () => {
|
2026-03-18 21:26:40 +02:00
|
|
|
|
it('Проект с циклическими parent_task_id рендерится без зависания и не показывает toggle', async () => {
|
2026-03-18 21:14:50 +02:00
|
|
|
|
// Специально создаём циклическую ссылку: 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)
|
2026-03-18 21:26:40 +02:00
|
|
|
|
// Рендер завершился без ошибок
|
2026-03-18 21:14:50 +02:00
|
|
|
|
expect(wrapper.exists()).toBe(true)
|
2026-03-18 21:47:49 +02:00
|
|
|
|
// rootFilteredTasks пустой: оба KIN-001 и KIN-002 имеют parent_task_id
|
|
|
|
|
|
// указывающий на существующую задачу → оба отфильтрованы (decision #817)
|
|
|
|
|
|
const taskLinks = wrapper.findAll('a[href^="/task/"]')
|
|
|
|
|
|
expect(taskLinks.length).toBe(0)
|
|
|
|
|
|
// toggle кнопок нет — задачи не попали в список (decision #826: независимые visited Set)
|
2026-03-18 21:26:40 +02:00
|
|
|
|
const toggleBtns = wrapper.findAll('[data-testid="task-toggle-children"]')
|
|
|
|
|
|
expect(toggleBtns.length).toBe(0)
|
2026-03-18 21:14:50 +02:00
|
|
|
|
})
|
|
|
|
|
|
})
|