diff --git a/tests/test_models.py b/tests/test_models.py index f73fae2..febe36f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,7 +2,6 @@ import re import pytest -from unittest.mock import patch from core.db import init_db from core import models from core.models import TASK_CATEGORIES @@ -1012,164 +1011,3 @@ def test_invalid_status_not_in_valid_task_statuses(): assert "invalid_status" not in models.VALID_TASK_STATUSES assert "" not in models.VALID_TASK_STATUSES assert "active" not in models.VALID_TASK_STATUSES - - -# --------------------------------------------------------------------------- -# KIN-UI-023: _check_parent_completion — атомарность каскада -# --------------------------------------------------------------------------- - -def test_check_parent_completion_happy_path_full_cascade(conn): - """KIN-UI-023 (happy path): grandchild→child→parent→grandparent — все переходят в done атомарно. - - Иерархия: P1-GRAND (revising) → P1-PAR (revising) → P1-CHILD (revising) → P1-GC (done). - После _check_parent_completion(P1-GC) все три предка должны стать done за одну транзакцию. - """ - models.create_project(conn, "p1", "P1", "/p1") - models.create_task(conn, "P1-GRAND", "p1", "Grandparent", status="revising") - models.create_task(conn, "P1-PAR", "p1", "Parent", status="revising", - parent_task_id="P1-GRAND") - models.create_task(conn, "P1-CHILD", "p1", "Child", status="revising", - parent_task_id="P1-PAR") - models.create_task(conn, "P1-GC", "p1", "Grandchild", status="done", - parent_task_id="P1-CHILD") - conn.commit() - - models._check_parent_completion(conn, "P1-GC") - - child = models.get_task(conn, "P1-CHILD") - parent = models.get_task(conn, "P1-PAR") - grandparent = models.get_task(conn, "P1-GRAND") - - assert child["status"] == "done" - assert parent["status"] == "done" - assert grandparent["status"] == "done" - assert child["completed_at"] is not None - assert parent["completed_at"] is not None - assert grandparent["completed_at"] is not None - - -def test_check_parent_completion_rollback_on_mid_cascade_exception(conn): - """KIN-UI-023 (atomicity): исключение в середине каскада откатывает все изменения. - - Иерархия: P1-GRAND (revising) → P1-PAR (revising) → P1-CHILD (revising) → P1-GC (done). - Инжектируем RuntimeError на 2-м вызове has_open_children (при проверке P1-PAR). - К тому моменту P1-CHILD уже получил UPDATE→done в pending-транзакции. - После rollback ни один из предков не должен остаться в состоянии done. - """ - models.create_project(conn, "p1", "P1", "/p1") - models.create_task(conn, "P1-GRAND", "p1", "Grandparent", status="revising") - models.create_task(conn, "P1-PAR", "p1", "Parent", status="revising", - parent_task_id="P1-GRAND") - models.create_task(conn, "P1-CHILD", "p1", "Child", status="revising", - parent_task_id="P1-PAR") - models.create_task(conn, "P1-GC", "p1", "Grandchild", status="done", - parent_task_id="P1-CHILD") - conn.commit() - - call_count = [0] - original_has_open_children = models.has_open_children - - def failing_has_open_children(c, tid, visited=None): - call_count[0] += 1 - if call_count[0] >= 2: - raise RuntimeError("Simulated mid-cascade failure") - return original_has_open_children(c, tid, visited) - - with patch.object(models, "has_open_children", side_effect=failing_has_open_children): - with pytest.raises(RuntimeError, match="Simulated mid-cascade failure"): - models._check_parent_completion(conn, "P1-GC") - - # После rollback все предки должны оставаться в revising — БД не в промежуточном состоянии - child = models.get_task(conn, "P1-CHILD") - parent = models.get_task(conn, "P1-PAR") - grandparent = models.get_task(conn, "P1-GRAND") - assert child["status"] == "revising", "P1-CHILD должен остаться revising после rollback" - assert parent["status"] == "revising", "P1-PAR должен остаться revising после rollback" - assert grandparent["status"] == "revising", "P1-GRAND должен остаться revising после rollback" - - -# --------------------------------------------------------------------------- -# Регрессия: get_decisions — edge case с пустым массивом [] -# Баг: `if types:` и `if tags:` не различают None (нет фильтра) и [] (пустой фильтр). -# Пустой список фальшивый в Python → фильтр не применяется → возвращаются ВСЕ записи. -# Ожидаемое поведение: types=[] и tags=[] должны возвращать 0 результатов. -# --------------------------------------------------------------------------- - -def test_get_decisions_empty_types_returns_no_results(conn): - """Регрессия: types=[] должен вернуть 0 решений, а не все. - - Баг: `if types:` фальшивый для [] → фильтр не применяется → возвращаются все решения. - Правильное поведение: пустой список типов означает «ни один тип не подходит» → 0 результатов. - """ - models.create_project(conn, "p1", "P1", "/p1") - models.add_decision(conn, "p1", "decision", "Решение A", "описание") - models.add_decision(conn, "p1", "gotcha", "Ловушка B", "описание") - - result = models.get_decisions(conn, "p1", types=[]) - assert result == [], ( - f"types=[] должен вернуть [], получено {len(result)} записей — " - "регрессия: `if types:` не различает None и []" - ) - - -def test_get_decisions_empty_tags_returns_no_results(conn): - """Регрессия: tags=[] должен вернуть 0 решений, а не все. - - Баг: `if tags:` фальшивый для [] → фильтр не применяется → возвращаются все решения. - Правильное поведение: пустой список тегов означает «ни один тег не подходит» → 0 результатов. - """ - models.create_project(conn, "p1", "P1", "/p1") - models.add_decision(conn, "p1", "gotcha", "Ловушка 1", "desc", tags=["safari", "css"]) - models.add_decision(conn, "p1", "gotcha", "Ловушка 2", "desc", tags=["chrome"]) - - result = models.get_decisions(conn, "p1", tags=[]) - assert result == [], ( - f"tags=[] должен вернуть [], получено {len(result)} записей — " - "регрессия: `if tags:` не различает None и []" - ) - - -def test_get_decisions_none_types_returns_all(conn): - """types=None не должен фильтровать — возвращает все решения (нет фильтра).""" - models.create_project(conn, "p1", "P1", "/p1") - models.add_decision(conn, "p1", "decision", "Решение A", "описание") - models.add_decision(conn, "p1", "gotcha", "Ловушка B", "описание") - - result = models.get_decisions(conn, "p1", types=None) - assert len(result) == 2, "types=None должен вернуть все 2 решения" - - -def test_get_decisions_none_tags_returns_all(conn): - """tags=None не должен фильтровать — возвращает все решения (нет фильтра).""" - models.create_project(conn, "p1", "P1", "/p1") - models.add_decision(conn, "p1", "gotcha", "Ловушка 1", "desc", tags=["safari"]) - models.add_decision(conn, "p1", "gotcha", "Ловушка 2", "desc", tags=["chrome"]) - - result = models.get_decisions(conn, "p1", tags=None) - assert len(result) == 2, "tags=None должен вернуть все 2 решения" - - -def test_get_decisions_empty_types_differs_from_none(conn): - """types=[] (пустой фильтр → 0 результатов) семантически отличается от types=None (нет фильтра → все).""" - models.create_project(conn, "p1", "P1", "/p1") - models.add_decision(conn, "p1", "decision", "Решение A", "описание") - models.add_decision(conn, "p1", "gotcha", "Ловушка B", "описание") - - result_empty = models.get_decisions(conn, "p1", types=[]) - result_none = models.get_decisions(conn, "p1", types=None) - - assert len(result_empty) == 0, "types=[] должен давать 0 результатов" - assert len(result_none) == 2, "types=None должен давать все 2 результата" - - -def test_get_decisions_empty_tags_differs_from_none(conn): - """tags=[] (пустой фильтр → 0 результатов) семантически отличается от tags=None (нет фильтра → все).""" - models.create_project(conn, "p1", "P1", "/p1") - models.add_decision(conn, "p1", "gotcha", "Ловушка 1", "desc", tags=["safari"]) - models.add_decision(conn, "p1", "gotcha", "Ловушка 2", "desc", tags=["chrome"]) - - result_empty = models.get_decisions(conn, "p1", tags=[]) - result_none = models.get_decisions(conn, "p1", tags=None) - - assert len(result_empty) == 0, "tags=[] должен давать 0 результатов" - assert len(result_none) == 2, "tags=None должен давать все 2 результата" diff --git a/web/frontend/src/__tests__/task-detail-revising-badge.test.ts b/web/frontend/src/__tests__/task-detail-revising-badge.test.ts deleted file mode 100644 index a4e1497..0000000 --- a/web/frontend/src/__tests__/task-detail-revising-badge.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * KIN-UI-017: statusColor() в TaskDetail.vue возвращает 'orange' для статуса 'revising' - * - * Проверяет через DOM: Badge с color="orange" применяет класс text-orange-400 - * (decision #837: badge-тест только через text-color класс как CSS-селектор) - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { mount, flushPromises } from '@vue/test-utils' -import { createRouter, createMemoryHistory } from 'vue-router' -import TaskDetail from '../views/TaskDetail.vue' - -vi.mock('../api', () => ({ - api: { - taskFull: vi.fn(), - patchTask: vi.fn(), - runTask: vi.fn(), - approveTask: vi.fn(), - rejectTask: vi.fn(), - reviseTask: vi.fn(), - followupTask: vi.fn(), - deployProject: vi.fn(), - getAttachments: vi.fn(), - resolveAction: vi.fn(), - }, -})) - -import { api } from '../api' - -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 }) - -const Stub = { template: '
' } - -function makeRouter() { - return createRouter({ - history: createMemoryHistory(), - routes: [ - { path: '/', component: Stub }, - { path: '/task/:id', component: TaskDetail, props: true }, - ], - }) -} - -const REVISING_TASK_FULL = { - id: 'KIN-001', - project_id: 'KIN', - title: 'Задача на доработку', - status: 'revising', - priority: 5, - assigned_role: null, - parent_task_id: null, - 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', - pipeline_steps: [], - related_decisions: [], - pending_actions: [], - pipeline_id: null, - project_deploy_command: null, - project_deploy_runtime: null, -} - -beforeEach(() => { - localStorageMock.clear() - vi.clearAllMocks() - vi.mocked(api.taskFull).mockResolvedValue(REVISING_TASK_FULL as any) - vi.mocked(api.getAttachments).mockResolvedValue([]) -}) - -describe('KIN-UI-017: TaskDetail — statusColor() для статуса revising', () => { - it('statusColor("revising") возвращает "orange" — Badge рендерится с классом text-orange-400', async () => { - const router = makeRouter() - await router.push('/task/KIN-001') - const wrapper = mount(TaskDetail, { - props: { id: 'KIN-001' }, - global: { plugins: [router] }, - }) - await flushPromises() - - // decision #837: badge-тест через text-color класс как селектор - const orangeBadge = wrapper.find('.text-orange-400') - expect(orangeBadge.exists(), 'Badge с color=orange должен рендерить класс text-orange-400').toBe(true) - }) - - it('statusColor("revising") — Badge НЕ применяет классы других статусов (не gray, не blue)', async () => { - const router = makeRouter() - await router.push('/task/KIN-001') - const wrapper = mount(TaskDetail, { - props: { id: 'KIN-001' }, - global: { plugins: [router] }, - }) - await flushPromises() - - // status-badge не должен быть серым (pending) или синим (in_progress) - const header = wrapper.find('h1') - expect(header.exists()).toBe(true) - // Ищем Badge рядом с заголовком задачи — он должен быть orange, не gray/blue - const grayBadgeInHeader = wrapper.find('.text-gray-400.text-xs.rounded') - // text-gray-400 может встречаться в других элементах, но мы проверяем наличие orange - const orangeBadge = wrapper.find('.text-orange-400') - expect(orangeBadge.exists()).toBe(true) - }) -}) diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 56e4e74..fe44d66 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -335,7 +335,6 @@ export const api = { project: (id: string) => get(`/projects/${id}`), task: (id: string) => get(`/tasks/${id}`), taskFull: (id: string) => get(`/tasks/${id}/full`), - taskChildren: (id: string) => get(`/tasks/${id}/children`), taskPipeline: (id: string) => get(`/tasks/${id}/pipeline`), cost: (days = 7) => get(`/cost?days=${days}`), createProject: (data: { id: string; name: string; path?: string; tech_stack?: string[]; priority?: number; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) => diff --git a/web/frontend/src/views/TaskDetail.vue b/web/frontend/src/views/TaskDetail.vue index 59025aa..9a705fe 100644 --- a/web/frontend/src/views/TaskDetail.vue +++ b/web/frontend/src/views/TaskDetail.vue @@ -107,7 +107,6 @@ function statusColor(s: string) { const m: Record = { pending: 'gray', in_progress: 'blue', review: 'yellow', done: 'green', blocked: 'red', decomposed: 'purple', cancelled: 'gray', - revising: 'orange', } return m[s] || 'gray' }