Files
orchestrator/tests/test_review_parse.py
claude-bot 03c3d77cac
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
feat(stage-engine): embed verbatim reviewer/tester findings in rollback task_desc
При заворотах на development task_desc теперь несёт дословный must-fix текст
(P0/P1 ревьюера, причина FAIL тестера) вместо одной ссылки на файл — developer-
агент видит суть претензий сразу и не повторяет ту же ошибку, экономя retry-
бюджет и токены общего инстанса.

- Новый defensive-модуль src/review_parse.py (never-raise): extract_review_findings
  (P0/P1 из 12-review.md ## Findings), extract_test_failures (фрагмент тела
  13-test-report.md: pytest output / FAIL-строки / Итог), усечение по лимиту.
- Две rollback-ветки stage_engine: встраивают текст + сохраняют ссылку на полный
  файл; graceful-фоллбэк на ссылку-строку при битом/пустом артефакте.
- Последовательность отката, retry-счётчик, поля AdvanceResult, реестр QG_CHECKS
  не менялись.
- Доки: README (Stage Engine / Откаты), CHANGELOG.
- Тесты: tests/test_review_parse.py, test_stage_engine.py::TestRollbackTaskDescEmbedding.

Refs: ORCH-046

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 04:42:11 +00:00

238 lines
8.1 KiB
Python
Raw Permalink 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.
"""Unit tests for src/review_parse (ORCH-046).
Covers the defensive extractors that pull verbatim must-fix text out of the
reviewer / tester artifacts for embedding into the rollback ``task_desc``:
- extract_review_findings (12-review.md, ## Findings -> P0/P1)
- extract_test_failures (13-test-report.md, pytest/FAIL/Итог excerpt)
Both must NEVER raise (return "" on missing/broken/empty input) and must ignore
template placeholders / non-must-fix severities. See 04-test-plan.yaml (TC-01..08).
"""
import os
import tempfile
import pytest
from src.review_parse import (
extract_review_findings,
extract_test_failures,
MAX_FINDINGS_CHARS,
MAX_FAILURES_CHARS,
)
@pytest.fixture
def write_file(tmp_path):
def _w(name: str, content: str) -> str:
p = tmp_path / name
p.write_text(content, encoding="utf-8")
return str(p)
return _w
# ---------------------------------------------------------------------------
# extract_review_findings
# ---------------------------------------------------------------------------
_REVIEW_WITH_FINDINGS = """---
type: review
work_item_id: ORCH-046
verdict: REQUEST_CHANGES
version: 1
---
# Review ORCH-046
## Summary
Несколько проблем.
## Findings
### P0 — Blocker
- [ ] Документация не обновлена при изменении src/review_parse.py
### P1 — Must fix
- [ ] extract_test_failures не обрабатывает пустой отчёт
- [ ] Отсутствует docstring у _section_body
### P2 — Should fix
- [ ] Переименовать переменную blocks в more descriptive
## Документация
Требует обновления README.
"""
class TestExtractReviewFindings:
def test_tc01_returns_verbatim_p0_p1(self, write_file):
"""TC-01: P0/P1 findings present -> verbatim text returned (AC-1, AC-5)."""
path = write_file("12-review.md", _REVIEW_WITH_FINDINGS)
out = extract_review_findings(path)
# P0 + P1 verbatim items present.
assert "Документация не обновлена при изменении src/review_parse.py" in out
assert "extract_test_failures не обрабатывает пустой отчёт" in out
assert "Отсутствует docstring у _section_body" in out
# Subsection headers preserved.
assert "P0" in out and "P1" in out
# P2 must NOT leak in.
assert "Переименовать переменную" not in out
def test_tc02_only_p2_p3_returns_empty(self, write_file):
"""TC-02: only P2/P3 (no must-fix P0/P1) -> '' (AC-5)."""
content = """---
verdict: REQUEST_CHANGES
---
## Findings
### P0 — Blocker
- [ ] <описание> (если есть)
### P1 — Must fix
- [ ] <описание> (если есть)
### P2 — Should fix
- [ ] Косметика в naming
"""
path = write_file("12-review.md", content)
assert extract_review_findings(path) == ""
def test_tc03_missing_file_returns_empty(self):
"""TC-03: non-existent path -> '' without raising (AC-4)."""
missing = os.path.join(tempfile.gettempdir(), "no-such-review-orch046.md")
assert extract_review_findings(missing) == ""
def test_tc04_broken_or_no_findings_section_returns_empty(self, write_file):
"""TC-04: empty file / markdown without ## Findings -> '' (AC-4, AC-5)."""
# Empty file.
assert extract_review_findings(write_file("empty.md", "")) == ""
# No Findings section.
no_section = "# Review\n\n## Summary\nвсё хорошо\n"
assert extract_review_findings(write_file("nofind.md", no_section)) == ""
# Broken YAML frontmatter (unterminated) — body parsing still graceful.
broken = "---\nverdict: [unclosed\n# Review\nno findings here\n"
assert extract_review_findings(write_file("broken.md", broken)) == ""
def test_tc05_long_findings_truncated(self, write_file):
"""TC-05: very long findings truncated to limit with marker (AC-1)."""
big_item = "- [ ] " + ("x" * 5000)
content = f"## Findings\n\n### P0 — Blocker\n{big_item}\n"
path = write_file("12-review.md", content)
out = extract_review_findings(path)
assert len(out) <= MAX_FINDINGS_CHARS + len("\n…(truncated)")
assert "…(truncated)" in out
def test_case_insensitive_and_dash_tolerant_header(self, write_file):
"""P0/P1 recognized regardless of case / dash style."""
content = """## Findings
### p0 - blocker
- [ ] Нижний регистр заголовка
### P1 — Must fix
- [ ] Em-dash заголовок
"""
out = extract_review_findings(write_file("12-review.md", content))
assert "Нижний регистр заголовка" in out
assert "Em-dash заголовок" in out
def test_never_raises_on_directory_path(self, tmp_path):
"""Passing a directory path must not raise -> ''."""
assert extract_review_findings(str(tmp_path)) == ""
# ---------------------------------------------------------------------------
# extract_test_failures
# ---------------------------------------------------------------------------
_REPORT_FAIL = """---
type: test-report
work_item_id: ORCH-046
result: FAIL
---
# Test Report — ORCH-046
## Окружение
- Python: 3.12
## Результаты
| TC ID | Описание | Результат |
|-------|----------|-----------|
| TC-01 | парсер findings | PASS |
| TC-09 | rollback task_desc | FAIL |
## Вывод pytest
FAILED tests/test_stage_engine.py::TestReviewerRequestChanges::test_embed - AssertionError
1 failed, 40 passed in 2.13s
## Итог
FAIL
"""
class TestExtractTestFailures:
def test_tc06_extracts_pytest_output(self, write_file):
"""TC-06: relevant body excerpt (pytest output) from FAIL report (AC-2, AC-5)."""
path = write_file("13-test-report.md", _REPORT_FAIL)
out = extract_test_failures(path)
assert "FAILED tests/test_stage_engine.py" in out
assert "1 failed, 40 passed" in out
def test_priority_falls_back_to_fail_rows(self, write_file):
"""No pytest section -> FAIL rows of the results table are used."""
content = """---
result: FAIL
---
## Результаты
| TC ID | Описание | Результат |
|-------|----------|-----------|
| TC-01 | ok | PASS |
| TC-09 | broken | FAIL |
## Итог
FAIL
"""
out = extract_test_failures(write_file("13-test-report.md", content))
assert "TC-09" in out
assert "broken" in out
# PASS rows are not failure-relevant.
assert "TC-01" not in out
def test_priority_falls_back_to_itog(self, write_file):
"""No pytest section and no FAIL rows -> Итог summary is used."""
content = """---
result: FAIL
---
## Итог
Регресс упал: смотрите CI лог.
"""
out = extract_test_failures(write_file("13-test-report.md", content))
assert "Регресс упал" in out
def test_tc07_missing_file_returns_empty(self):
"""TC-07: non-existent path -> '' without raising (AC-4)."""
missing = os.path.join(tempfile.gettempdir(), "no-such-report-orch046.md")
assert extract_test_failures(missing) == ""
def test_tc08_broken_or_empty_report_returns_empty(self, write_file):
"""TC-08: empty / section-less report -> '' without raising (AC-4, AC-5)."""
assert extract_test_failures(write_file("empty.md", "")) == ""
no_sections = "---\nresult: FAIL\n---\n\n# Test Report\nничего полезного\n"
assert extract_test_failures(write_file("nosec.md", no_sections)) == ""
def test_long_failures_truncated(self, write_file):
"""Long pytest output is truncated to the limit with a marker."""
big = "x" * 5000
content = f"## Вывод pytest\n{big}\n"
out = extract_test_failures(write_file("13-test-report.md", content))
assert len(out) <= MAX_FAILURES_CHARS + len("\n…(truncated)")
assert "…(truncated)" in out
def test_never_raises_on_directory_path(self, tmp_path):
assert extract_test_failures(str(tmp_path)) == ""