Compare commits
3 commits
54365b4345
...
38aebb7323
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38aebb7323 | ||
|
|
992dab962a | ||
|
|
0277a599bc |
4 changed files with 282 additions and 0 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import pytest
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
from core.db import init_db
|
from core.db import init_db
|
||||||
from core import models
|
from core import models
|
||||||
from core.models import TASK_CATEGORIES
|
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 "invalid_status" not in models.VALID_TASK_STATUSES
|
||||||
assert "" not in models.VALID_TASK_STATUSES
|
assert "" not in models.VALID_TASK_STATUSES
|
||||||
assert "active" 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 результата"
|
||||||
|
|
|
||||||
118
web/frontend/src/__tests__/task-detail-revising-badge.test.ts
Normal file
118
web/frontend/src/__tests__/task-detail-revising-badge.test.ts
Normal file
|
|
@ -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<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 })
|
||||||
|
|
||||||
|
const Stub = { template: '<div />' }
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -335,6 +335,7 @@ export const api = {
|
||||||
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
|
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
|
||||||
task: (id: string) => get<Task>(`/tasks/${id}`),
|
task: (id: string) => get<Task>(`/tasks/${id}`),
|
||||||
taskFull: (id: string) => get<TaskFull>(`/tasks/${id}/full`),
|
taskFull: (id: string) => get<TaskFull>(`/tasks/${id}/full`),
|
||||||
|
taskChildren: (id: string) => get<Task[]>(`/tasks/${id}/children`),
|
||||||
taskPipeline: (id: string) => get<PipelineStep[]>(`/tasks/${id}/pipeline`),
|
taskPipeline: (id: string) => get<PipelineStep[]>(`/tasks/${id}/pipeline`),
|
||||||
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
|
cost: (days = 7) => get<CostEntry[]>(`/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 }) =>
|
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 }) =>
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ function statusColor(s: string) {
|
||||||
const m: Record<string, string> = {
|
const m: Record<string, string> = {
|
||||||
pending: 'gray', in_progress: 'blue', review: 'yellow',
|
pending: 'gray', in_progress: 'blue', review: 'yellow',
|
||||||
done: 'green', blocked: 'red', decomposed: 'purple', cancelled: 'gray',
|
done: 'green', blocked: 'red', decomposed: 'purple', cancelled: 'gray',
|
||||||
|
revising: 'orange',
|
||||||
}
|
}
|
||||||
return m[s] || 'gray'
|
return m[s] || 'gray'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue