"""Integration tests for generated template code quality.

These tests generate actual projects and run linting/type checking on them.
They are slower but ensure the template produces valid, well-formatted code.
"""

import subprocess
from pathlib import Path

import pytest

from fastapi_gen.config import (
    AIFrameworkType,
    BackgroundTaskType,
    CIType,
    DatabaseType,
    FrontendType,
    LLMProviderType,
    LogfireFeatures,
    OAuthProvider,
    OrmType,
    ProjectConfig,
    RAGFeatures,
    VectorStoreType,
)
from fastapi_gen.generator import generate_project


@pytest.fixture
def generated_project_minimal(tmp_path: Path) -> Path:
    """Generate a minimal project for testing."""
    config = ProjectConfig(
        project_name="test_minimal",
        database=DatabaseType.POSTGRESQL,
        enable_logfire=False,
        enable_docker=False,
        ci_type=CIType.NONE,
        background_tasks=BackgroundTaskType.NONE,
    )
    return generate_project(config, tmp_path)


@pytest.fixture
def generated_project_full(tmp_path: Path) -> Path:
    """Generate a full-featured project for testing."""
    config = ProjectConfig(
        project_name="test_full",
        project_description="A fully featured test project",
        author_name="Test Author",
        author_email="test@example.com",
        database=DatabaseType.POSTGRESQL,
        oauth_provider=OAuthProvider.GOOGLE,
        enable_session_management=True,
        enable_logfire=True,
        logfire_features=LogfireFeatures(
            fastapi=True,
            database=True,
            redis=True,
            celery=True,
            httpx=True,
        ),
        background_tasks=BackgroundTaskType.CELERY,
        enable_redis=True,
        enable_caching=True,
        enable_rate_limiting=True,
        enable_pagination=True,
        enable_sentry=True,
        enable_prometheus=True,
        enable_admin_panel=True,
        enable_websockets=True,
        enable_file_storage=True,
        enable_webhooks=True,
        enable_cors=True,
        enable_pytest=True,
        enable_precommit=True,
        enable_makefile=True,
        enable_docker=True,
        ci_type=CIType.GITHUB,
        enable_kubernetes=True,
        frontend=FrontendType.NEXTJS,
    )
    return generate_project(config, tmp_path)


class TestGeneratedTemplateRuff:
    """Test that generated code passes ruff linting."""

    @pytest.mark.slow
    def test_minimal_project_passes_ruff(self, generated_project_minimal: Path) -> None:
        """Test minimal project passes ruff check."""
        backend_path = generated_project_minimal / "backend"
        result = subprocess.run(
            ["uvx", "ruff", "check", str(backend_path)],
            capture_output=True,
            text=True,
            cwd=backend_path,
        )
        assert result.returncode == 0, f"Ruff failed:\n{result.stdout}\n{result.stderr}"

    @pytest.mark.slow
    def test_full_project_passes_ruff(self, generated_project_full: Path) -> None:
        """Test full project passes ruff check."""
        backend_path = generated_project_full / "backend"
        result = subprocess.run(
            ["uvx", "ruff", "check", str(backend_path)],
            capture_output=True,
            text=True,
            cwd=backend_path,
        )
        assert result.returncode == 0, f"Ruff failed:\n{result.stdout}\n{result.stderr}"


class TestGeneratedTemplateTy:
    """Test that generated code passes ty type checking."""

    @pytest.mark.slow
    def test_minimal_project_passes_ty(self, generated_project_minimal: Path) -> None:
        """Test minimal project passes ty check."""
        backend_path = generated_project_minimal / "backend"
        result = subprocess.run(
            ["uv", "run", "ty", "check"],
            capture_output=True,
            text=True,
            cwd=backend_path,
        )
        assert result.returncode == 0, f"ty failed:\n{result.stdout}\n{result.stderr}"

    @pytest.mark.slow
    def test_full_project_passes_ty(self, generated_project_full: Path) -> None:
        """Test full project passes ty check."""
        backend_path = generated_project_full / "backend"
        result = subprocess.run(
            ["uv", "run", "ty", "check"],
            capture_output=True,
            text=True,
            cwd=backend_path,
        )
        assert result.returncode == 0, f"ty failed:\n{result.stdout}\n{result.stderr}"


class TestGeneratedTemplateAgentsFolder:
    """Test that agents folder is always created since AI agent is always enabled."""

    @pytest.mark.slow
    def test_agents_folder_created_in_minimal(self, generated_project_minimal: Path) -> None:
        """Test that agents folder exists in minimal project (AI agent is always enabled)."""
        agents_path = generated_project_minimal / "backend" / "app" / "agents"
        assert agents_path.exists(), "agents/ folder should exist since AI agent is always enabled"

    @pytest.mark.slow
    def test_agents_folder_created_when_enabled(self, generated_project_full: Path) -> None:
        """Test that agents folder exists when AI agent is enabled."""
        agents_path = generated_project_full / "backend" / "app" / "agents"
        assert agents_path.exists(), "agents/ folder should exist when AI is enabled"
        assert (agents_path / "__init__.py").exists()
        assert (agents_path / "assistant.py").exists()


class TestGeneratedTemplateSyntax:
    """Test that generated Python files have valid syntax."""

    @pytest.mark.slow
    def test_minimal_project_valid_python_syntax(self, generated_project_minimal: Path) -> None:
        """Test all Python files in minimal project have valid syntax."""
        backend_path = generated_project_minimal / "backend"
        python_files = list(backend_path.rglob("*.py"))

        for py_file in python_files:
            result = subprocess.run(
                ["python3", "-m", "py_compile", str(py_file)],
                capture_output=True,
                text=True,
            )
            assert result.returncode == 0, f"Syntax error in {py_file}:\n{result.stderr}"

    @pytest.mark.slow
    def test_full_project_valid_python_syntax(self, generated_project_full: Path) -> None:
        """Test all Python files in full project have valid syntax."""
        backend_path = generated_project_full / "backend"
        python_files = list(backend_path.rglob("*.py"))

        for py_file in python_files:
            result = subprocess.run(
                ["python3", "-m", "py_compile", str(py_file)],
                capture_output=True,
                text=True,
            )
            assert result.returncode == 0, f"Syntax error in {py_file}:\n{result.stderr}"


# ---------------------------------------------------------------------------
# Configuration matrix
# ---------------------------------------------------------------------------
#
# These tests generate projects across the meaningful axes of the template
# (AI framework, database, ORM, integrations) and run ruff + ty against each.
# Without this matrix, framework-specific bugs (e.g. an undefined `file_ids`
# in the DeepAgents WebSocket handler) only surface when a user picks that
# combination. Keep the list small but representative — one entry per branch
# of significant Jinja conditionals.

MATRIX_CONFIGS: dict[str, dict] = {
    # --- AI frameworks ----------------------------------------------------
    "langchain_pg_celery": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.LANGCHAIN,
        enable_redis=True,
        background_tasks=BackgroundTaskType.CELERY,
        enable_langsmith=True,
    ),
    "langgraph_pg": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.LANGGRAPH,
        enable_redis=True,
        background_tasks=BackgroundTaskType.NONE,
    ),
    "deepagents_pg": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.DEEPAGENTS,
        enable_redis=True,
        background_tasks=BackgroundTaskType.NONE,
    ),
    "deepagents_pg_minimal": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.DEEPAGENTS,
        background_tasks=BackgroundTaskType.NONE,
    ),
    "pydantic_deep_pg": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_DEEP,
        enable_redis=True,
        background_tasks=BackgroundTaskType.NONE,
    ),
    # --- LLM providers ----------------------------------------------------
    "openrouter": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_AI,
        llm_provider=LLMProviderType.OPENROUTER,
        background_tasks=BackgroundTaskType.NONE,
    ),
    "anthropic": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_AI,
        llm_provider=LLMProviderType.ANTHROPIC,
        background_tasks=BackgroundTaskType.NONE,
    ),
    # --- Databases / ORM --------------------------------------------------
    "sqlmodel_pg": dict(
        database=DatabaseType.POSTGRESQL,
        orm_type=OrmType.SQLMODEL,
        background_tasks=BackgroundTaskType.NONE,
    ),
    # --- Optional integrations -------------------------------------------
    "channels_pg": dict(
        database=DatabaseType.POSTGRESQL,
        background_tasks=BackgroundTaskType.NONE,
        use_telegram=True,
        use_slack=True,
    ),
    # --- Charts -----------------------------------------------------------
    "pydantic_ai_charts": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_AI,
        enable_logfire=False,
        enable_charts=True,
        background_tasks=BackgroundTaskType.NONE,
    ),
    "pydantic_ai_code_execution": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_AI,
        enable_logfire=False,
        enable_code_execution=True,
        enable_charts=True,
        background_tasks=BackgroundTaskType.NONE,
    ),
    # SkillsToolset wiring: enable_skills attaches the toolset (no bundled
    # skills here, so it no-ops at runtime) — generated together with code
    # execution to confirm the two coexist and lint/type-check cleanly.
    "pydantic_ai_skills": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_AI,
        enable_logfire=False,
        enable_skills=True,
        enable_code_execution=True,
        enable_charts=True,
        background_tasks=BackgroundTaskType.NONE,
    ),
    # Deep research: PG path (asyncpg pool + persistence) with the frontend panel.
    "pydantic_ai_deep_research_pg": dict(
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_AI,
        enable_logfire=False,
        enable_deep_research=True,
        enable_charts=True,
        frontend=FrontendType.NEXTJS,
        background_tasks=BackgroundTaskType.NONE,
    ),
    "rag_pgvector": dict(
        database=DatabaseType.POSTGRESQL,
        background_tasks=BackgroundTaskType.NONE,
        rag_features=RAGFeatures(
            enable_rag=True,
            vector_store=VectorStoreType.PGVECTOR,
        ),
    ),
    "rag_qdrant_pg": dict(
        database=DatabaseType.POSTGRESQL,
        background_tasks=BackgroundTaskType.NONE,
        rag_features=RAGFeatures(
            enable_rag=True,
            vector_store=VectorStoreType.QDRANT,
        ),
    ),
    "frontend_oauth_pg": dict(
        database=DatabaseType.POSTGRESQL,
        background_tasks=BackgroundTaskType.NONE,
        frontend=FrontendType.NEXTJS,
        oauth_provider=OAuthProvider.GOOGLE,
    ),
}


@pytest.fixture(params=sorted(MATRIX_CONFIGS), scope="module")
def matrix_project(
    tmp_path_factory: pytest.TempPathFactory, request: pytest.FixtureRequest
) -> Path:
    """Generate a project for a single matrix entry, shared across checks."""
    name: str = request.param
    out_dir = tmp_path_factory.mktemp(f"matrix_{name}")
    config = ProjectConfig(project_name=f"matrix_{name}", **MATRIX_CONFIGS[name])
    return generate_project(config, out_dir)


class TestGeneratedTemplateMatrix:
    """Regression matrix across AI frameworks, databases, and integrations.

    Locks in working configurations so framework-specific bugs (e.g. raw
    queries in a single agent path) cannot slip through CI.
    """

    @pytest.mark.slow
    def test_passes_ruff(self, matrix_project: Path) -> None:
        backend_path = matrix_project / "backend"
        result = subprocess.run(
            ["uvx", "ruff", "check", str(backend_path)],
            capture_output=True,
            text=True,
            cwd=backend_path,
        )
        assert result.returncode == 0, f"Ruff failed:\n{result.stdout}\n{result.stderr}"

    @pytest.mark.slow
    def test_passes_ty(self, matrix_project: Path, request: pytest.FixtureRequest) -> None:
        backend_path = matrix_project / "backend"
        result = subprocess.run(
            ["uv", "run", "ty", "check"],
            capture_output=True,
            text=True,
            cwd=backend_path,
        )
        assert result.returncode == 0, f"ty failed:\n{result.stdout}\n{result.stderr}"


# ---------------------------------------------------------------------------
# AntV charts / Leaflet maps — generated-content checks
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# Deep research — generated-content checks
# ---------------------------------------------------------------------------


@pytest.fixture(scope="module")
def generated_project_deep_research(tmp_path_factory: pytest.TempPathFactory) -> Path:
    """Generate a pydantic_ai + PG + deep_research + frontend project for content checks."""
    config = ProjectConfig(
        project_name="test_deep_research",
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_AI,
        enable_logfire=False,
        enable_deep_research=True,
        enable_charts=True,
        frontend=FrontendType.NEXTJS,
        background_tasks=BackgroundTaskType.NONE,
    )
    return generate_project(config, tmp_path_factory.mktemp("deep_research"))


@pytest.fixture(scope="module")
def generated_project_deep_research_off(tmp_path_factory: pytest.TempPathFactory) -> Path:
    """Generate the same project with deep research OFF to assert cleanup."""
    config = ProjectConfig(
        project_name="test_no_deep_research",
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_AI,
        enable_logfire=False,
        enable_deep_research=False,
        frontend=FrontendType.NEXTJS,
        background_tasks=BackgroundTaskType.NONE,
    )
    return generate_project(config, tmp_path_factory.mktemp("no_deep_research"))


class TestGeneratedDeepResearch:
    """Verify the deep-research feature renders correctly and is fully gated.

    Guards the production-readiness fixes: the interstitial-tool gate (so a final
    report carrying a chart still streams), the no-conversation_id fallback, the
    shared RESEARCH_TOOL_NAMES set, and the post_gen cleanup when the flag is off.
    """

    @pytest.mark.slow
    def test_research_module_defines_shared_tool_names(
        self, generated_project_deep_research: Path
    ) -> None:
        """research.py owns the canonical interstitial-tool set and a telemetry flush."""
        research = generated_project_deep_research / "backend" / "app" / "services" / "research.py"
        assert research.exists()
        content = research.read_text()
        assert "RESEARCH_TOOL_NAMES = frozenset(" in content
        assert "async def flush(self)" in content

    @pytest.mark.slow
    def test_session_gates_buffer_on_research_tools(
        self, generated_project_deep_research: Path
    ) -> None:
        """The buffer drops text only for research-tool steps, not content tools (charts)."""
        session = (
            generated_project_deep_research / "backend" / "app" / "services" / "agent_session.py"
        )
        content = session.read_text()
        assert "from app.services.research import RESEARCH_TOOL_NAMES, ResearchToolkit" in content
        assert "made_research_call = any(name in RESEARCH_TOOL_NAMES" in content
        # The old indiscriminate drop must be gone.
        assert "made_tool_call" not in content

    @pytest.mark.slow
    def test_session_falls_back_without_conversation_id(
        self, generated_project_deep_research: Path
    ) -> None:
        """Without a conversation_id, deep_research is disabled rather than left tool-less."""
        session = (
            generated_project_deep_research / "backend" / "app" / "services" / "agent_session.py"
        )
        content = session.read_text()
        assert "if deep_research and self.current_conversation_id:" in content
        assert "deep_research = False" in content

    @pytest.mark.slow
    def test_frontend_panel_mirrors_backend(self, generated_project_deep_research: Path) -> None:
        """The frontend tool-name set is generated and documents the backend mirror."""
        panel = (
            generated_project_deep_research
            / "frontend"
            / "src"
            / "components"
            / "chat"
            / "research-panel.tsx"
        )
        assert panel.exists()
        content = panel.read_text()
        assert "RESEARCH_TOOL_NAMES" in content
        assert "app/services/research.py" in content

    @pytest.mark.slow
    def test_no_jinja_leftovers(self, generated_project_deep_research: Path) -> None:
        """No unrendered Jinja markers in the deep-research files."""
        files = [
            generated_project_deep_research / "backend" / "app" / "services" / "research.py",
            generated_project_deep_research / "backend" / "app" / "db" / "todo_pool.py",
            generated_project_deep_research / "backend" / "app" / "services" / "agent_session.py",
            generated_project_deep_research
            / "frontend"
            / "src"
            / "components"
            / "chat"
            / "research-panel.tsx",
        ]
        for f in files:
            content = f.read_text()
            # Check for unrendered Jinja markers (cookiecutter vars / block tags),
            # not bare {{ which legitimately appears in JSX inline objects.
            assert "cookiecutter" not in content, f"Unrendered cookiecutter var in {f.name}"
            assert "{%- if" not in content and "{%- endif" not in content, (
                f"Unrendered Jinja block in {f.name}"
            )

    @pytest.mark.slow
    def test_files_removed_when_disabled(self, generated_project_deep_research_off: Path) -> None:
        """post_gen removes every deep-research file when the flag is off."""
        root = generated_project_deep_research_off
        removed = [
            root / "backend" / "app" / "services" / "research.py",
            root / "backend" / "app" / "db" / "todo_pool.py",
            root / "frontend" / "src" / "components" / "chat" / "research-panel.tsx",
            root / "frontend" / "src" / "stores" / "research-store.ts",
            root / "frontend" / "src" / "stores" / "chat-mode-store.ts",
        ]
        for f in removed:
            assert not f.exists(), f"{f} should be removed when deep research is off"

    @pytest.mark.slow
    def test_disabled_session_keeps_original_streaming(
        self, generated_project_deep_research_off: Path
    ) -> None:
        """With the flag off, the session has no research buffering."""
        session = (
            generated_project_deep_research_off
            / "backend"
            / "app"
            / "services"
            / "agent_session.py"
        )
        content = session.read_text()
        assert "RESEARCH_TOOL_NAMES" not in content
        assert "made_research_call" not in content


# ---------------------------------------------------------------------------
# Frontend Docker build — generated-content checks
# ---------------------------------------------------------------------------


@pytest.fixture(scope="module")
def generated_project_frontend_docker(tmp_path_factory: pytest.TempPathFactory) -> Path:
    """Generate a frontend + docker + oauth project for Dockerfile/compose checks."""
    config = ProjectConfig(
        project_name="test_fe_docker",
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_AI,
        enable_logfire=False,
        frontend=FrontendType.NEXTJS,
        oauth_provider=OAuthProvider.GOOGLE,
        enable_docker=True,
        background_tasks=BackgroundTaskType.NONE,
    )
    return generate_project(config, tmp_path_factory.mktemp("fe_docker"))


class TestGeneratedFrontendDocker:
    """Guard the frontend Docker build against the regressions in issue #97.

    `make dev-frontend` failed because the Dockerfile copied a `bun.lockb*` glob
    that never matched the current text `bun.lock`, the healthcheck shelled out to
    a `curl` the oven/bun image doesn't ship, and the NEXT_PUBLIC_* client vars
    were never passed as build args.
    """

    @pytest.mark.slow
    def test_dockerfile_copies_text_lockfile(self, generated_project_frontend_docker: Path) -> None:
        """The COPY glob must match bun.lock (text), not only the legacy bun.lockb."""
        dockerfile = (generated_project_frontend_docker / "frontend" / "Dockerfile").read_text()
        assert "COPY package.json bun.lock* ./" in dockerfile
        assert "bun.lockb* ./" not in dockerfile

    @pytest.mark.slow
    def test_dockerfile_passes_public_env_build_args(
        self, generated_project_frontend_docker: Path
    ) -> None:
        """NEXT_PUBLIC_* are inlined at build time, so they must be build args."""
        dockerfile = (generated_project_frontend_docker / "frontend" / "Dockerfile").read_text()
        assert "ARG NEXT_PUBLIC_API_URL=" in dockerfile
        assert "ARG NEXT_PUBLIC_WS_URL=" in dockerfile
        # oauth project → providers baked into the client bundle for the buttons.
        assert "ARG NEXT_PUBLIC_OAUTH_PROVIDERS=google" in dockerfile

    @pytest.mark.slow
    def test_dockerfile_public_copy_has_chown(
        self, generated_project_frontend_docker: Path
    ) -> None:
        """The public/ copy must set ownership like the other runner copies."""
        dockerfile = (generated_project_frontend_docker / "frontend" / "Dockerfile").read_text()
        assert "COPY --from=builder --chown=nextjs:bun /app/public ./public" in dockerfile

    @pytest.mark.slow
    def test_compose_healthcheck_avoids_curl(self, generated_project_frontend_docker: Path) -> None:
        """The healthcheck must invoke bun, not the curl the oven/bun image lacks."""
        compose = (generated_project_frontend_docker / "docker-compose.frontend.yml").read_text()
        assert '"curl"' not in compose
        assert '"bun"' in compose

    @pytest.mark.slow
    def test_compose_passes_public_env_build_args(
        self, generated_project_frontend_docker: Path
    ) -> None:
        """Compose must forward the NEXT_PUBLIC_* build args to the image."""
        compose = (generated_project_frontend_docker / "docker-compose.frontend.yml").read_text()
        assert "NEXT_PUBLIC_API_URL=http://localhost:" in compose
        assert "NEXT_PUBLIC_WS_URL=ws://localhost:" in compose


# ---------------------------------------------------------------------------
# Makefile — generated-content checks
# ---------------------------------------------------------------------------


@pytest.fixture(scope="module")
def generated_project_makefile(tmp_path_factory: pytest.TempPathFactory) -> Path:
    """Generate a postgres + redis + docker + frontend project for Makefile checks."""
    config = ProjectConfig(
        project_name="test_makefile",
        database=DatabaseType.POSTGRESQL,
        ai_framework=AIFrameworkType.PYDANTIC_AI,
        enable_logfire=False,
        enable_redis=True,
        frontend=FrontendType.NEXTJS,
        enable_docker=True,
        background_tasks=BackgroundTaskType.NONE,
    )
    return generate_project(config, tmp_path_factory.mktemp("makefile"))


class TestGeneratedMakefile:
    """Guard the Makefile against the Compose v1 regression in issue #96.

    `make docker-db` (and the other docker-* targets) shelled out to the legacy
    `docker-compose` binary, which errors with "No such file or directory" on
    systems that only ship the Compose v2 plugin (`docker compose`).
    """

    @pytest.mark.slow
    def test_no_legacy_compose_v1_command(self, generated_project_makefile: Path) -> None:
        """No recipe may invoke the hyphenated `docker-compose` command."""
        makefile = (generated_project_makefile / "Makefile").read_text()
        # The command is `docker-compose ` + args; the `.yml` filenames keep the hyphen.
        assert "docker-compose " not in makefile

    @pytest.mark.slow
    def test_docker_db_uses_compose_v2(self, generated_project_makefile: Path) -> None:
        """The docker-db target must drive the v2 `docker compose` plugin."""
        makefile = (generated_project_makefile / "Makefile").read_text()
        assert "docker compose up -d db" in makefile
        # Compose filenames are still hyphenated — only the command changed.
        assert "docker-compose.frontend.yml" in makefile


# ---------------------------------------------------------------------------
# Dev dependencies — generated-content checks
# ---------------------------------------------------------------------------


class TestGeneratedDevDependencies:
    """Guard `make install` against the pre-commit spawn failure in issue #95.

    Dev tools lived under `[project.optional-dependencies].dev`, which only got
    installed via uv's deprecated `dev`-extra special-casing. On uv versions
    where `uv sync --dev` targets the PEP 735 group instead, the extra was
    skipped and `pre-commit`/`ruff`/`ty` were never installed — so `make install`
    failed with "Failed to spawn: pre-commit". They must live in
    `[dependency-groups]`, which `--dev` installs deterministically.
    """

    @pytest.mark.slow
    def test_dev_tools_in_dependency_group(self, generated_project_makefile: Path) -> None:
        """Dev tools must be a PEP 735 dependency group, not an optional-deps extra."""
        pyproject = (generated_project_makefile / "backend" / "pyproject.toml").read_text()
        assert "[dependency-groups]" in pyproject
        # The fragile dev-extra form must be gone (check the table header line,
        # not an incidental mention in a comment).
        assert "[project.optional-dependencies]" not in pyproject.splitlines()

    @pytest.mark.slow
    def test_precommit_in_dev_group(self, generated_project_makefile: Path) -> None:
        """pre-commit must be declared so `uv sync --dev` can install it."""
        pyproject = (generated_project_makefile / "backend" / "pyproject.toml").read_text()
        group = pyproject.split("[dependency-groups]", 1)[1]
        assert "pre-commit" in group
        assert "ruff" in group and "ty" in group
