При заворотах на 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>
238 lines
8.1 KiB
Python
238 lines
8.1 KiB
Python
"""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)) == ""
|