feat(KIN-012): UI auto/review mode toggle, autopilot indicator, persist project mode in DB

- TaskDetail: hide Approve/Reject buttons in auto mode, show "Автопилот активен" badge
- TaskDetail: execution_mode persisted per-task via PATCH /api/tasks/{id}
- TaskDetail: loadMode reads DB value, falls back to localStorage per project
- TaskDetail: back navigation preserves status filter via ?back_status query param
- ProjectView: toggleMode now persists to DB via PATCH /api/projects/{id}
- ProjectView: loadMode reads project.execution_mode from DB first
- ProjectView: task list shows 🔓 badge for auto-mode tasks
- ProjectView: status filter synced to URL query param ?status=
- api.ts: add patchProject(), execution_mode field on Project interface
- core/db.py, core/models.py: execution_mode columns + migration for projects & tasks
- web/api.py: PATCH /api/projects/{id} and PATCH /api/tasks/{id} support execution_mode
- tests: 256 tests pass, new test_auto_mode.py with 60+ auto mode tests
- frontend: vitest config added for component tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gros Frumos 2026-03-15 20:02:01 +02:00
parent 3cb516193b
commit 4a27bf0693
12 changed files with 2698 additions and 30 deletions

View file

@ -0,0 +1,280 @@
/**
* KIN-011: Тесты сохранения фильтра статусов при навигации
*
* Проверяет:
* 1. Выбор фильтра обновляет URL (?status=...)
* 2. Прямая ссылка с query param инициализирует фильтр
* 3. Фильтр показывает только задачи с нужным статусом
* 4. Сброс фильтра удаляет param из URL
* 5. goBack() вызывает router.back() при наличии истории
* 6. goBack() делает push на /project/:id без истории
* 7. После router.back() URL проекта восстанавливается с фильтром
*/
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'
import TaskDetail from '../views/TaskDetail.vue'
// Мок api — factory без ссылок на внешние переменные (vi.mock хоистится)
vi.mock('../api', () => ({
api: {
project: vi.fn(),
taskFull: vi.fn(),
runTask: vi.fn(),
auditProject: vi.fn(),
createTask: vi.fn(),
patchTask: vi.fn(),
},
}))
// Импортируем мок после объявления vi.mock
import { api } from '../api'
const Stub = { template: '<div />' }
const MOCK_PROJECT = {
id: 'KIN',
name: 'Kin',
path: '/projects/kin',
status: 'active',
priority: 5,
tech_stack: ['python', 'vue'],
created_at: '2024-01-01',
total_tasks: 3,
done_tasks: 1,
active_tasks: 1,
blocked_tasks: 0,
review_tasks: 0,
tasks: [
{
id: 'KIN-001', project_id: 'KIN', title: 'Task 1', status: 'pending',
priority: 5, assigned_role: null, parent_task_id: null,
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
},
{
id: 'KIN-002', project_id: 'KIN', title: 'Task 2', status: 'in_progress',
priority: 3, assigned_role: null, parent_task_id: null,
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
},
{
id: 'KIN-003', project_id: 'KIN', title: 'Task 3', status: 'done',
priority: 1, assigned_role: null, parent_task_id: null,
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
},
],
decisions: [],
modules: [],
}
const MOCK_TASK_FULL = {
id: 'KIN-002',
project_id: 'KIN',
title: 'Task 2',
status: 'in_progress',
priority: 3,
assigned_role: null,
parent_task_id: null,
brief: null,
spec: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
pipeline_steps: [],
related_decisions: [],
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Stub },
{ path: '/project/:id', component: ProjectView, props: true },
{ path: '/task/:id', component: TaskDetail, props: true },
],
})
}
// localStorage mock для jsdom-окружения
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 })
beforeEach(() => {
localStorageMock.clear()
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any)
vi.mocked(api.taskFull).mockResolvedValue(MOCK_TASK_FULL as any)
})
// ─────────────────────────────────────────────────────────────
// ProjectView: фильтр ↔ URL
// ─────────────────────────────────────────────────────────────
describe('KIN-011: ProjectView — фильтр и URL', () => {
it('1. При выборе фильтра URL обновляется query param ?status', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// Изначально status нет в URL
expect(router.currentRoute.value.query.status).toBeUndefined()
// Меняем фильтр через select (первый select — фильтр статусов)
const select = wrapper.find('select')
await select.setValue('in_progress')
await flushPromises()
// URL должен содержать ?status=in_progress
expect(router.currentRoute.value.query.status).toBe('in_progress')
})
it('2. Прямая ссылка ?status=in_progress инициализирует фильтр в select', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=in_progress')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// select должен показывать in_progress
const select = wrapper.find('select')
expect((select.element as HTMLSelectElement).value).toBe('in_progress')
})
it('3. Прямая ссылка ?status=in_progress показывает только задачи с этим статусом', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=in_progress')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// Должна быть видна только KIN-002 (in_progress)
const links = wrapper.findAll('a[href^="/task/"]')
expect(links).toHaveLength(1)
expect(links[0].text()).toContain('KIN-002')
})
it('4. Сброс фильтра (пустое значение) удаляет status из URL', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=done')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// Сброс фильтра
const select = wrapper.find('select')
await select.setValue('')
await flushPromises()
// status должен исчезнуть из URL
expect(router.currentRoute.value.query.status).toBeUndefined()
})
it('5. Без фильтра отображаются все 3 задачи', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
const links = wrapper.findAll('a[href^="/task/"]')
expect(links).toHaveLength(3)
})
})
// ─────────────────────────────────────────────────────────────
// TaskDetail: goBack сохраняет URL проекта с фильтром
// ─────────────────────────────────────────────────────────────
describe('KIN-011: TaskDetail — возврат с сохранением URL', () => {
it('6. goBack() вызывает router.back() когда window.history.length > 1', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=in_progress')
await router.push('/task/KIN-002')
const backSpy = vi.spyOn(router, 'back')
// Эмулируем наличие истории
Object.defineProperty(window, 'history', {
value: { ...window.history, length: 3 },
configurable: true,
})
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-002' },
global: { plugins: [router] },
})
await flushPromises()
// Первая кнопка — кнопка "назад" (← KIN)
const backBtn = wrapper.find('button')
await backBtn.trigger('click')
expect(backSpy).toHaveBeenCalled()
})
it('7. goBack() без истории делает push на /project/:id', async () => {
const router = makeRouter()
await router.push('/task/KIN-002')
const pushSpy = vi.spyOn(router, 'push')
// Эмулируем отсутствие истории
Object.defineProperty(window, 'history', {
value: { ...window.history, length: 1 },
configurable: true,
})
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-002' },
global: { plugins: [router] },
})
await flushPromises()
const backBtn = wrapper.find('button')
await backBtn.trigger('click')
expect(pushSpy).toHaveBeenCalledWith({ path: '/project/KIN', query: undefined })
})
it('8. После router.back() URL проекта восстанавливается с query param ?status', async () => {
const router = makeRouter()
// Навигация: проект с фильтром → задача
await router.push('/project/KIN?status=in_progress')
await router.push('/task/KIN-002')
expect(router.currentRoute.value.path).toBe('/task/KIN-002')
// Возвращаемся назад
router.back()
await flushPromises()
// URL должен вернуться к /project/KIN?status=in_progress
expect(router.currentRoute.value.path).toBe('/project/KIN')
expect(router.currentRoute.value.query.status).toBe('in_progress')
})
})