Newer
Older
navi-1 / tests / unit / skills / test_models.py
"""Tests for the skills/protocols document format."""

import pytest

from navi.skills.models import (
    SkillError,
    content_hash,
    parse_document,
    render_for_agent,
)

SKILL = """---
kind: skill
name: Разбор логов
description: Как быстро найти причину в большом логе.
area: workflows
tags: [logs, debugging]
triggers:
  - "нужно найти ошибку в логах"
---

Сначала грепай по уровню ERROR, затем сужай по времени.
"""

PROTOCOL = """---
kind: protocol
name: Выключение инфраструктуры
description: Останавливает сервисы в порядке зависимостей.
area: infrastructure
tags: [power, shutdown]
steps:
  - Остановить приложения
  - Остановить БД
post_checks:
  - Прикладных юнитов не осталось
on_deviation: Вернуться к предыдущему шагу и доложить.
on_blocked: Остановить протокол и доложить.
on_already_done: Проверить статус и отметить шаг.
---

Известные ловушки в конце документа.
"""


class TestParse:
    def test_skill(self):
        doc = parse_document(SKILL)
        assert doc.kind == "skill"
        assert doc.name == "Разбор логов"
        assert doc.area == "workflows"
        assert doc.tags == ["logs", "debugging"]
        assert doc.triggers == ["нужно найти ошибку в логах"]
        assert "грепай" in doc.body
        assert doc.steps == []

    def test_protocol(self):
        doc = parse_document(PROTOCOL)
        assert doc.kind == "protocol"
        assert doc.steps == ["Остановить приложения", "Остановить БД"]
        assert doc.post_checks == ["Прикладных юнитов не осталось"]
        assert doc.on_deviation.startswith("Вернуться")
        assert "ловушки" in doc.body

    def test_kind_override_matches_frontmatter(self):
        assert parse_document(PROTOCOL, kind="protocol").kind == "protocol"

    def test_kind_mismatch_is_rejected(self):
        with pytest.raises(SkillError, match="front matter says kind"):
            parse_document(PROTOCOL, kind="skill")

    def test_missing_frontmatter(self):
        with pytest.raises(SkillError, match="front matter"):
            parse_document("just text")

    def test_invalid_yaml(self):
        with pytest.raises(SkillError, match="invalid YAML"):
            parse_document("---\nname: [unclosed\n---\nbody")

    def test_kind_required(self):
        with pytest.raises(SkillError, match="'kind' is required"):
            parse_document("---\nname: x\n---\nbody")

    def test_bad_kind(self):
        with pytest.raises(SkillError, match="'kind' must be one of"):
            parse_document("---\nkind: method\nname: x\n---\nbody")

    def test_name_required(self):
        with pytest.raises(SkillError, match="'name' is required"):
            parse_document("---\nkind: skill\n---\nbody")

    def test_skill_needs_a_body(self):
        with pytest.raises(SkillError, match="needs a body"):
            parse_document("---\nkind: skill\nname: x\n---\n")

    def test_bad_area(self):
        with pytest.raises(SkillError, match="'area' must be one of"):
            parse_document("---\nkind: skill\nname: x\narea: garage\n---\nbody")

    def test_protocol_missing_escape_hatches_lists_every_missing_key(self):
        doc = "---\nkind: protocol\nname: x\n---\nbody"
        with pytest.raises(SkillError) as exc:
            parse_document(doc)
        message = str(exc.value)
        for key in ("steps", "post_checks", "on_deviation", "on_blocked", "on_already_done"):
            assert key in message

    def test_scalar_trigger_becomes_list(self):
        doc = parse_document(
            "---\nkind: skill\nname: x\ntriggers: одна причина\n---\nbody"
        )
        assert doc.triggers == ["одна причина"]

    def test_unknown_keys_are_kept_as_extra(self):
        doc = parse_document("---\nkind: skill\nname: x\nowner_team: ops\n---\nbody")
        assert doc.extra == {"owner_team": "ops"}
        assert doc.frontmatter()["owner_team"] == "ops"

    def test_content_hash_is_stable(self):
        assert content_hash(SKILL) == content_hash(SKILL)
        assert content_hash(SKILL) != content_hash(PROTOCOL)


class TestRender:
    def test_skill_render(self):
        text = render_for_agent(parse_document(SKILL), entry_id="abc", version=2)
        assert text.startswith("SKILL: Разбор логов (v2)")
        assert "area: workflows" in text
        assert "use when:" in text
        assert "грепай" in text
        assert "id: abc" in text

    def test_protocol_render_has_steps_and_hatches(self):
        text = render_for_agent(parse_document(PROTOCOL), entry_id="p1", version=1)
        assert text.startswith("PROTOCOL: Выключение инфраструктуры")
        assert "1. Остановить приложения" in text
        assert "2. Остановить БД" in text
        assert "If the run goes off plan:" in text
        assert "If the protocol cannot be completed:" in text
        assert "If a step is already done:" in text