482 lines
21 KiB
Python
482 lines
21 KiB
Python
"""Tests for core/phases.py — Research Phase Pipeline (KIN-059).
|
||
|
||
Covers:
|
||
- validate_roles: фильтрация, дедубликация, удаление architect
|
||
- build_phase_order: канонический порядок + auto-architect
|
||
- create_project_with_phases: создание + первая фаза active
|
||
- approve_phase: переход статусов, активация следующей, sequential enforcement
|
||
- reject_phase: статус rejected, защита от неактивных фаз
|
||
- revise_phase: цикл revise→running, счётчик, сохранение комментария
|
||
"""
|
||
|
||
import pytest
|
||
from core.db import init_db
|
||
from core import models
|
||
from core.phases import (
|
||
RESEARCH_ROLES,
|
||
approve_phase,
|
||
build_phase_order,
|
||
create_project_with_phases,
|
||
reject_phase,
|
||
revise_phase,
|
||
validate_roles,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def conn():
|
||
"""KIN-059: изолированная in-memory БД для каждого теста."""
|
||
c = init_db(db_path=":memory:")
|
||
yield c
|
||
c.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# validate_roles
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_validate_roles_filters_unknown_roles():
|
||
"""KIN-059: неизвестные роли отфильтровываются из списка."""
|
||
result = validate_roles(["business_analyst", "wizard", "ghost"])
|
||
assert result == ["business_analyst"]
|
||
|
||
|
||
def test_validate_roles_strips_architect():
|
||
"""KIN-059: architect убирается из входных ролей — добавляется автоматически позже."""
|
||
result = validate_roles(["architect", "tech_researcher"])
|
||
assert "architect" not in result
|
||
assert "tech_researcher" in result
|
||
|
||
|
||
def test_validate_roles_deduplicates():
|
||
"""KIN-059: дублирующиеся роли удаляются, остаётся одна копия."""
|
||
result = validate_roles(["business_analyst", "business_analyst", "tech_researcher"])
|
||
assert result.count("business_analyst") == 1
|
||
|
||
|
||
def test_validate_roles_empty_input_returns_empty():
|
||
"""KIN-059: пустой список ролей → пустой результат."""
|
||
assert validate_roles([]) == []
|
||
|
||
|
||
def test_validate_roles_only_architect_returns_empty():
|
||
"""KIN-059: только architect во входе → пустой результат (architect не researcher)."""
|
||
assert validate_roles(["architect"]) == []
|
||
|
||
|
||
def test_validate_roles_strips_and_lowercases():
|
||
"""KIN-059: роли нормализуются: trim + lowercase."""
|
||
result = validate_roles([" Tech_Researcher ", "MARKETER"])
|
||
assert "tech_researcher" in result
|
||
assert "marketer" in result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# build_phase_order
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("roles,expected", [
|
||
# 1 исследователь → нет knowledge_synthesizer, architect последний
|
||
(
|
||
["business_analyst"],
|
||
["business_analyst", "architect"],
|
||
),
|
||
(
|
||
["tech_researcher"],
|
||
["tech_researcher", "architect"],
|
||
),
|
||
# ≥2 исследователей → knowledge_synthesizer авто-вставляется перед architect (KIN-DOCS-003, 2026-03-19)
|
||
(
|
||
["marketer", "business_analyst"],
|
||
["business_analyst", "marketer", "knowledge_synthesizer", "architect"],
|
||
),
|
||
(
|
||
["ux_designer", "market_researcher", "tech_researcher"],
|
||
["market_researcher", "tech_researcher", "ux_designer", "knowledge_synthesizer", "architect"],
|
||
),
|
||
])
|
||
def test_build_phase_order_canonical_order_and_appends_architect(roles, expected):
|
||
"""KIN-059: роли сортируются в канонический порядок, architect добавляется последним.
|
||
|
||
При ≥2 исследователях knowledge_synthesizer авто-вставляется перед architect.
|
||
"""
|
||
assert build_phase_order(roles) == expected
|
||
|
||
|
||
def test_build_phase_order_no_architect_if_no_researcher():
|
||
"""KIN-059: architect не добавляется если нет ни одного researcher."""
|
||
result = build_phase_order([])
|
||
assert result == []
|
||
assert "architect" not in result
|
||
|
||
|
||
def test_build_phase_order_architect_always_last():
|
||
"""KIN-059: architect всегда последний независимо от набора ролей."""
|
||
result = build_phase_order(["marketer", "legal_researcher", "business_analyst"])
|
||
assert result[-1] == "architect"
|
||
|
||
|
||
def test_build_phase_order_single_researcher_no_synthesizer():
|
||
"""KIN-DOCS-003: 1 исследователь → knowledge_synthesizer НЕ вставляется."""
|
||
result = build_phase_order(["business_analyst"])
|
||
assert "knowledge_synthesizer" not in result
|
||
assert result == ["business_analyst", "architect"]
|
||
|
||
|
||
def test_build_phase_order_two_researchers_inserts_synthesizer():
|
||
"""KIN-DOCS-003: 2 исследователя → knowledge_synthesizer авто-вставляется перед architect."""
|
||
result = build_phase_order(["market_researcher", "tech_researcher"])
|
||
assert "knowledge_synthesizer" in result
|
||
assert result.index("knowledge_synthesizer") == len(result) - 2
|
||
assert result[-1] == "architect"
|
||
|
||
|
||
def test_validate_roles_strips_knowledge_synthesizer():
|
||
"""KIN-DOCS-003: knowledge_synthesizer убирается из входных ролей — авто-управляемая роль."""
|
||
result = validate_roles(["knowledge_synthesizer", "business_analyst"])
|
||
assert "knowledge_synthesizer" not in result
|
||
assert "business_analyst" in result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# create_project_with_phases
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_project_with_phases_creates_project_and_phases(conn):
|
||
"""KIN-059: создание проекта с researcher-ролями создаёт и проект, и записи фаз."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "Project 1", "/path",
|
||
description="Тестовый проект", selected_roles=["business_analyst"],
|
||
)
|
||
assert result["project"]["id"] == "proj1"
|
||
# business_analyst + architect = 2 фазы
|
||
assert len(result["phases"]) == 2
|
||
|
||
|
||
def test_create_project_with_phases_first_phase_is_active(conn):
|
||
"""KIN-059: первая фаза сразу переходит в status=active и получает task_id."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["tech_researcher"],
|
||
)
|
||
first = result["phases"][0]
|
||
assert first["status"] == "active"
|
||
assert first["task_id"] is not None
|
||
|
||
|
||
def test_create_project_with_phases_other_phases_remain_pending(conn):
|
||
"""KIN-059: все фазы кроме первой остаются pending — не активируются без approve."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["market_researcher", "tech_researcher"],
|
||
)
|
||
# market_researcher, tech_researcher, architect → 3 фазы; knowledge_synthesizer не фаза (P1-001)
|
||
for phase in result["phases"][1:]:
|
||
assert phase["status"] == "pending"
|
||
|
||
|
||
def test_create_project_with_phases_raises_if_no_roles(conn):
|
||
"""KIN-059: ValueError при попытке создать проект без researcher-ролей."""
|
||
with pytest.raises(ValueError, match="[Aa]t least one research role"):
|
||
create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=[],
|
||
)
|
||
|
||
|
||
def test_create_project_with_phases_architect_auto_added_last(conn):
|
||
"""KIN-059: architect автоматически добавляется последним без явного указания."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["business_analyst"],
|
||
)
|
||
roles = [ph["role"] for ph in result["phases"]]
|
||
assert "architect" in roles
|
||
assert roles[-1] == "architect"
|
||
|
||
|
||
@pytest.mark.parametrize("roles", [
|
||
["business_analyst"],
|
||
["market_researcher", "tech_researcher"],
|
||
["legal_researcher", "ux_designer", "marketer"],
|
||
["business_analyst", "market_researcher", "legal_researcher",
|
||
"tech_researcher", "ux_designer", "marketer"],
|
||
])
|
||
def test_create_project_with_phases_architect_added_for_any_combination(conn, roles):
|
||
"""KIN-059: architect добавляется при любом наборе researcher-ролей."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=roles,
|
||
)
|
||
phase_roles = [ph["role"] for ph in result["phases"]]
|
||
assert "architect" in phase_roles
|
||
assert phase_roles[-1] == "architect"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# approve_phase
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_approve_phase_sets_status_approved(conn):
|
||
"""KIN-059: approve_phase устанавливает status=approved для текущей фазы."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["business_analyst"],
|
||
)
|
||
phase_id = result["phases"][0]["id"]
|
||
out = approve_phase(conn, phase_id)
|
||
assert out["phase"]["status"] == "approved"
|
||
|
||
|
||
def test_approve_phase_activates_next_phase(conn):
|
||
"""KIN-059: следующая фаза активируется только после approve предыдущей."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["business_analyst"],
|
||
)
|
||
first_phase_id = result["phases"][0]["id"]
|
||
out = approve_phase(conn, first_phase_id)
|
||
next_phase = out["next_phase"]
|
||
assert next_phase is not None
|
||
assert next_phase["status"] == "active"
|
||
assert next_phase["role"] == "architect"
|
||
|
||
|
||
def test_approve_phase_last_returns_no_next(conn):
|
||
"""KIN-059: approve последней фазы возвращает next_phase=None (workflow завершён)."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["business_analyst"],
|
||
)
|
||
# Approve business_analyst → architect активируется
|
||
first_id = result["phases"][0]["id"]
|
||
mid = approve_phase(conn, first_id)
|
||
architect_id = mid["next_phase"]["id"]
|
||
# Approve architect → no next
|
||
final = approve_phase(conn, architect_id)
|
||
assert final["next_phase"] is None
|
||
|
||
|
||
def test_approve_phase_not_active_raises(conn):
|
||
"""KIN-059: approve фазы в статусе != active бросает ValueError."""
|
||
models.create_project(conn, "proj1", "P1", "/path", description="Desc")
|
||
phase = models.create_phase(conn, "proj1", "business_analyst", 0)
|
||
# Фаза в статусе pending, не active
|
||
with pytest.raises(ValueError, match="not active"):
|
||
approve_phase(conn, phase["id"])
|
||
|
||
|
||
def test_pending_phase_not_started_without_approve(conn):
|
||
"""KIN-059: следующая фаза не стартует без approve предыдущей (нет автоактивации)."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["market_researcher", "tech_researcher"],
|
||
)
|
||
# Вторая фаза (tech_researcher) должна оставаться pending
|
||
second_phase = result["phases"][1]
|
||
assert second_phase["status"] == "pending"
|
||
assert second_phase["task_id"] is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# reject_phase
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_reject_phase_sets_status_rejected(conn):
|
||
"""KIN-059: reject_phase устанавливает status=rejected для фазы."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["tech_researcher"],
|
||
)
|
||
phase_id = result["phases"][0]["id"]
|
||
out = reject_phase(conn, phase_id, reason="Не релевантно")
|
||
assert out["status"] == "rejected"
|
||
|
||
|
||
def test_reject_phase_not_active_raises(conn):
|
||
"""KIN-059: reject_phase для pending-фазы бросает ValueError."""
|
||
models.create_project(conn, "proj1", "P1", "/path", description="Desc")
|
||
phase = models.create_phase(conn, "proj1", "tech_researcher", 0)
|
||
with pytest.raises(ValueError, match="not active"):
|
||
reject_phase(conn, phase["id"], reason="test")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# revise_phase
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_revise_phase_sets_status_revising(conn):
|
||
"""KIN-059: revise_phase устанавливает статус revising для фазы."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["ux_designer"],
|
||
)
|
||
phase_id = result["phases"][0]["id"]
|
||
out = revise_phase(conn, phase_id, comment="Нужно больше деталей")
|
||
assert out["phase"]["status"] == "revising"
|
||
|
||
|
||
def test_revise_phase_creates_new_task_with_comment(conn):
|
||
"""KIN-059: revise_phase создаёт новую задачу с revise_comment в brief."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["marketer"],
|
||
)
|
||
phase_id = result["phases"][0]["id"]
|
||
comment = "Добавь анализ конкурентов"
|
||
out = revise_phase(conn, phase_id, comment=comment)
|
||
new_task = out["new_task"]
|
||
assert new_task is not None
|
||
assert new_task["brief"]["revise_comment"] == comment
|
||
|
||
|
||
def test_revise_phase_increments_revise_count(conn):
|
||
"""KIN-059: revise_phase увеличивает счётчик revise_count с каждым вызовом."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["marketer"],
|
||
)
|
||
phase_id = result["phases"][0]["id"]
|
||
out1 = revise_phase(conn, phase_id, comment="Первая ревизия")
|
||
assert out1["phase"]["revise_count"] == 1
|
||
out2 = revise_phase(conn, phase_id, comment="Вторая ревизия")
|
||
assert out2["phase"]["revise_count"] == 2
|
||
|
||
|
||
def test_revise_phase_saves_comment_on_phase(conn):
|
||
"""KIN-059: revise_phase сохраняет комментарий в поле revise_comment фазы."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["business_analyst"],
|
||
)
|
||
phase_id = result["phases"][0]["id"]
|
||
comment = "Уточни целевую аудиторию"
|
||
out = revise_phase(conn, phase_id, comment=comment)
|
||
assert out["phase"]["revise_comment"] == comment
|
||
|
||
|
||
def test_revise_phase_pending_raises(conn):
|
||
"""KIN-059: revise_phase для pending-фазы бросает ValueError."""
|
||
models.create_project(conn, "proj1", "P1", "/path", description="Desc")
|
||
phase = models.create_phase(conn, "proj1", "marketer", 0)
|
||
with pytest.raises(ValueError, match="cannot be revised"):
|
||
revise_phase(conn, phase["id"], comment="test")
|
||
|
||
|
||
def test_revise_phase_revising_status_allows_another_revise(conn):
|
||
"""KIN-059: фаза в статусе revising допускает повторный вызов revise (цикл)."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["business_analyst"],
|
||
)
|
||
phase_id = result["phases"][0]["id"]
|
||
revise_phase(conn, phase_id, comment="Первая ревизия")
|
||
# Фаза теперь revising — повторный revise должен проходить
|
||
out = revise_phase(conn, phase_id, comment="Вторая ревизия")
|
||
assert out["phase"]["revise_count"] == 2
|
||
|
||
|
||
def test_revise_phase_updates_task_id_to_new_task(conn):
|
||
"""KIN-059: после revise phase.task_id указывает на новую задачу."""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["market_researcher"],
|
||
)
|
||
phase = result["phases"][0]
|
||
original_task_id = phase["task_id"]
|
||
out = revise_phase(conn, phase["id"], comment="Пересмотреть")
|
||
new_task_id = out["phase"]["task_id"]
|
||
assert new_task_id != original_task_id
|
||
assert new_task_id == out["new_task"]["id"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Regression: knowledge_synthesizer не создаётся как pipeline-фаза (P1-001)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_project_with_phases_knowledge_synthesizer_not_in_phases(conn):
|
||
"""Регрессия P1-001: knowledge_synthesizer не создаётся как pipeline-фаза.
|
||
|
||
При ≥2 исследователях build_phase_order включает knowledge_synthesizer,
|
||
но create_project_with_phases должен фильтровать его из DB-фаз.
|
||
"""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["market_researcher", "tech_researcher"],
|
||
)
|
||
roles = [ph["role"] for ph in result["phases"]]
|
||
assert "knowledge_synthesizer" not in roles
|
||
|
||
|
||
def test_create_project_with_phases_two_researchers_creates_three_phases(conn):
|
||
"""Регрессия P1-001: 2 исследователя → 3 фазы (researcher + researcher + architect), не 4.
|
||
|
||
До фикса knowledge_synthesizer создавался как фаза → 4 фазы вместо 3.
|
||
"""
|
||
result = create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["market_researcher", "tech_researcher"],
|
||
)
|
||
assert len(result["phases"]) == 3
|
||
roles = [ph["role"] for ph in result["phases"]]
|
||
assert roles == ["market_researcher", "tech_researcher", "architect"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Edge cases: "effectively empty" arrays — непустой вход, ноль валидных ролей (P1-001)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_project_with_phases_raises_if_only_architect(conn):
|
||
"""P1-001 edge case: [architect] → ValueError после фильтрации (architect не researcher)."""
|
||
with pytest.raises(ValueError, match="[Aa]t least one research role"):
|
||
create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["architect"],
|
||
)
|
||
|
||
|
||
def test_create_project_with_phases_raises_if_only_knowledge_synthesizer(conn):
|
||
"""P1-001 edge case: [knowledge_synthesizer] → ValueError (авто-управляемая роль фильтруется)."""
|
||
with pytest.raises(ValueError, match="[Aa]t least one research role"):
|
||
create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["knowledge_synthesizer"],
|
||
)
|
||
|
||
|
||
def test_create_project_with_phases_raises_if_architect_and_synthesizer_only(conn):
|
||
"""P1-001 edge case: [architect, knowledge_synthesizer] → ValueError (оба авто-управляемые)."""
|
||
with pytest.raises(ValueError, match="[Aa]t least one research role"):
|
||
create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["architect", "knowledge_synthesizer"],
|
||
)
|
||
|
||
|
||
def test_create_project_with_phases_raises_if_unknown_roles_only(conn):
|
||
"""P1-001 edge case: только неизвестные роли → пустой список после validate_roles → ValueError."""
|
||
with pytest.raises(ValueError, match="[Aa]t least one research role"):
|
||
create_project_with_phases(
|
||
conn, "proj1", "P1", "/path",
|
||
description="Desc", selected_roles=["wizard", "ghost", "oracle"],
|
||
)
|
||
|
||
|
||
def test_build_phase_order_architect_only_returns_empty():
|
||
"""P1-001 edge case: build_phase_order([architect]) → [] (architect не researcher)."""
|
||
result = build_phase_order(["architect"])
|
||
assert result == []
|
||
|
||
|
||
def test_validate_roles_architect_and_synthesizer_returns_empty():
|
||
"""P1-001 edge case: validate_roles([architect, knowledge_synthesizer]) → [] (оба отфильтровываются)."""
|
||
result = validate_roles(["architect", "knowledge_synthesizer"])
|
||
assert result == []
|