kin/tests/test_phases.py
2026-03-19 19:25:38 +02:00

482 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 == []