"""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", [ ( ["business_analyst"], ["business_analyst", "architect"], ), ( ["tech_researcher"], ["tech_researcher", "architect"], ), ( ["marketer", "business_analyst"], ["business_analyst", "marketer", "architect"], ), ( ["ux_designer", "market_researcher", "tech_researcher"], ["market_researcher", "tech_researcher", "ux_designer", "architect"], ), ]) def test_build_phase_order_canonical_order_and_appends_architect(roles, expected): """KIN-059: роли сортируются в канонический порядок, 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" # --------------------------------------------------------------------------- # 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 фазы 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"]