From 0277a599bc4af3bf28a2728af726b009fe852271 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 21:53:07 +0200 Subject: [PATCH 1/2] kin: KIN-UI-017-frontend_dev --- web/frontend/src/api.ts | 1 + web/frontend/src/views/TaskDetail.vue | 1 + 2 files changed, 2 insertions(+) diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index fe44d66..56e4e74 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -335,6 +335,7 @@ 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 9a705fe..59025aa 100644 --- a/web/frontend/src/views/TaskDetail.vue +++ b/web/frontend/src/views/TaskDetail.vue @@ -107,6 +107,7 @@ 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' } From 38aebb732398b155a4014c54c735070f4cc6025c Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 21:56:43 +0200 Subject: [PATCH 2/2] kin: auto-commit after pipeline --- tests/test_models.py | 162 ++++++++++++++++++ .../task-detail-revising-badge.test.ts | 118 +++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 web/frontend/src/__tests__/task-detail-revising-badge.test.ts diff --git a/tests/test_models.py b/tests/test_models.py index febe36f..f73fae2 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,6 +2,7 @@ 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 @@ -1011,3 +1012,164 @@ 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 new file mode 100644 index 0000000..a4e1497 --- /dev/null +++ b/web/frontend/src/__tests__/task-detail-revising-badge.test.ts @@ -0,0 +1,118 @@ +/** + * 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) + }) +})