432 lines
17 KiB
Python
432 lines
17 KiB
Python
"""Tests for context health report (TASK-021)."""
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
from textwrap import dedent
|
|
|
|
|
|
def _get_project_root() -> Path:
|
|
current = Path(__file__).resolve().parent.parent
|
|
while current != current.parent:
|
|
if (current / "pyproject.toml").exists() or (current / ".git").exists():
|
|
return current
|
|
current = current.parent
|
|
return Path.cwd()
|
|
|
|
|
|
@pytest.fixture()
|
|
def tmp_project(tmp_path):
|
|
"""Create a minimal project root with context files and discovery answers."""
|
|
root = tmp_path / "testproject"
|
|
root.mkdir()
|
|
(root / "context").mkdir()
|
|
|
|
# Write discovery-log.md with various confidence levels
|
|
(root / "context" / "discovery-log.md").write_text(
|
|
"# Discovery Log\n\n"
|
|
"| ID | Question | Answer | Confidence | Follow-up needed | Linked decision | Linked task | Date |\n"
|
|
"|---|---|---|---|---|---|---|---|\n"
|
|
'| Q-001 | What problem are we solving? | A CLI tool for discovery workflow | High | No | | | 2026-06-03 |\n'
|
|
'| Q-002 | Who is the user? | Internal developers and small dev teams | Medium | No | | | 2026-06-03 |\n'
|
|
'| Q-008 | What must not happen? | No third-party SaaS dependencies | Low | No | | | 2026-06-03 |\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Write product-brief.md with TBD placeholders
|
|
(root / "context" / "product-brief.md").write_text(
|
|
"# Product Brief\n\n## Problem\n\nTBD\n\n## Users\n\nTBD\n\n## Success Criteria\n\nDone\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Write architecture.md with some TBDs
|
|
(root / "context" / "architecture.md").write_text(
|
|
"# Architecture\n\n## Overview\n\nTDB — Not yet defined.\n\n## Core Components\n\nTBD\n\n## Data Flow\n\nDone\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Write decisions.md (present, non-empty)
|
|
(root / "context" / "decisions.md").write_text(
|
|
"# Decisions\n\n| ID | Decision | Reason | Date |\n|---|---|---|---|\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Write risks.md and assumptions.md
|
|
(root / "context" / "risks.md").write_text(
|
|
"# Risks\n\n| ID | Risk | Impact | Mitigation | Status |\n|---|---|---|---|---|\n",
|
|
encoding="utf-8",
|
|
)
|
|
(root / "context" / "assumptions.md").write_text(
|
|
"# Assumptions\n\n| ID | Assumption | Confidence | Validation Needed |\n|---|---|---|---|\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Write open-questions.md (present)
|
|
(root / "context" / "open-questions.md").write_text(
|
|
"# Open Questions\n\n| ID | Question | Reason | Owner | Status |\n|---|---|---|---|---|\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Write company-context.md, development-context.md, infrastructure-context.md with TBDs
|
|
(root / "context" / "company-context.md").write_text(
|
|
"# Company Context\n\n## Mission\n\nTBD\n\n## Products & Services\n\nDone.\n",
|
|
encoding="utf-8",
|
|
)
|
|
(root / "context" / "development-context.md").write_text(
|
|
"# Development Context\n\n## Tech Stack\n\nPython 3.12, Typer, Rich.\n\n## Coding Standards\n\nTBD\n",
|
|
encoding="utf-8",
|
|
)
|
|
(root / "context" / "infrastructure-context.md").write_text(
|
|
"# Infrastructure Context\n\n## Hosting\n\nTBD — Cloud provider details.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Write agent-guidelines.md and repository-context.md (present)
|
|
(root / "context" / "agent-guidelines.md").write_text("# Agent Guidelines\n\nDone.\n", encoding="utf-8")
|
|
(root / "context" / "repository-context.md").write_text("# Repository Context\n\nPurpose: Test project.\n", encoding="utf-8")
|
|
|
|
# Write PROJECT_STATE.md, TASKS.md, TEST_PLAN.md, RUN_LOG.md, AGENT_HANDOFF.md
|
|
(root / "PROJECT_STATE.md").write_text("# Project State\n\nCurrent Stage: BUILDING\n", encoding="utf-8")
|
|
(root / "TASKS.md").write_text("# TASKS.md\n## TASK-001\nStatus: Done\n", encoding="utf-8")
|
|
(root / "TEST_PLAN.md").write_text("# TEST_PLAN.md\n## Tests\nDone.\n", encoding="utf-8")
|
|
(root / "RUN_LOG.md").write_text("# RUN_LOG.md\n| Date | Event | Task | Notes |\n|---|---|---|---|\n", encoding="utf-8")
|
|
(root / "AGENT_HANDOFF.md").write_text("# Agent Handoff\n## Current Stage\nBUILDING\n", encoding="utf-8")
|
|
|
|
# Create .rdb directory
|
|
(root / ".rdb").mkdir()
|
|
|
|
return root
|
|
|
|
|
|
class TestExpectedFiles:
|
|
"""Tests for expected file reporting."""
|
|
|
|
def test_all_expected_files_returned(self, tmp_project):
|
|
"""Every standard context file should appear in the expected list."""
|
|
from rdb_discovery.context_status import _expected_files
|
|
|
|
files = _expected_files(tmp_project)
|
|
assert len(files) == 18
|
|
|
|
def test_missing_file_detected(self, tmp_project):
|
|
"""Files that don't exist should be marked as missing."""
|
|
# project-brief.md is not created by this fixture
|
|
(tmp_project / "context" / "project-brief.md").unlink(missing_ok=True)
|
|
|
|
from rdb_discovery.context_status import _check_expected_files
|
|
|
|
results = _check_expected_files(tmp_project)
|
|
paths_by_status = {r["path"]: r["status"] for r in results}
|
|
assert paths_by_status.get("context/project-brief.md") == "missing"
|
|
|
|
def test_present_file_detected(self, tmp_project):
|
|
"""Files that exist should be marked as present."""
|
|
from rdb_discovery.context_status import _check_expected_files
|
|
|
|
results = _check_expected_files(tmp_project)
|
|
paths_by_status = {r["path"]: r["status"] for r in results}
|
|
assert paths_by_status["TASKS.md"] == "present"
|
|
assert paths_by_status["PROJECT_STATE.md"] == "present"
|
|
|
|
|
|
class TestTbdDetection:
|
|
"""Tests for TBD placeholder detection."""
|
|
|
|
def test_tbd_sections_detected(self, tmp_project):
|
|
"""Sections with TBD/TDB placeholders should be reported."""
|
|
from rdb_discovery.context_status import _check_tbd_sections
|
|
|
|
findings = _check_tbd_sections(tmp_project)
|
|
assert len(findings) > 0
|
|
|
|
paths = {f["file"] for f in findings}
|
|
assert "product-brief.md" in paths # has TBD sections
|
|
assert "architecture.md" in paths # has TDB section
|
|
|
|
def test_no_tbd_in_clean_files(self, tmp_project):
|
|
"""Files without TBD placeholders should not appear."""
|
|
from rdb_discovery.context_status import _check_tbd_sections
|
|
|
|
findings = _check_tbd_sections(tmp_project)
|
|
# TASKS.md has "Status: Done" — no TBD
|
|
for f in findings:
|
|
assert f["file"] != "TASKS.md" or not any(
|
|
"TBD" in str(getattr(f, "placeholder", "")) or "TDB" in str(getattr(f, "placeholder", ""))
|
|
for _ in [1]
|
|
)
|
|
|
|
def test_tbd_sections_returns_empty_for_no_context_dir(self):
|
|
"""Should return empty list when no context dir exists."""
|
|
from rdb_discovery.context_status import _check_tbd_sections
|
|
|
|
findings = _check_tbd_sections(_get_project_root() / "nonexistent")
|
|
assert findings == []
|
|
|
|
|
|
class TestLowConfidence:
|
|
"""Tests for low-confidence discovery answer detection."""
|
|
|
|
def test_low_confidence_detected(self, tmp_project):
|
|
"""Low-confidence answers should be detected."""
|
|
from rdb_discovery.context_status import _check_low_confidence
|
|
|
|
low = _check_low_confidence(tmp_project)
|
|
assert len(low) == 1
|
|
assert low[0]["id"] == "Q-008"
|
|
assert low[0]["confidence"] == "Low"
|
|
|
|
def test_high_confidence_not_flagged(self, tmp_project):
|
|
"""High-confidence answers should not appear in low_confidence results."""
|
|
from rdb_discovery.context_status import _check_low_confidence
|
|
|
|
low = _check_low_confidence(tmp_project)
|
|
ids = {a["id"] for a in low}
|
|
assert "Q-001" not in ids # Q-001 is High confidence
|
|
|
|
def test_medium_confidence_not_flagged(self, tmp_project):
|
|
"""Medium-confidence answers should not appear in low_confidence results."""
|
|
from rdb_discovery.context_status import _check_low_confidence
|
|
|
|
low = _check_low_confidence(tmp_project)
|
|
ids = {a["id"] for a in low}
|
|
assert "Q-002" not in ids # Q-002 is Medium confidence
|
|
|
|
|
|
class TestHealthScore:
|
|
"""Tests for health score computation."""
|
|
|
|
def test_full_score_when_everything_complete(self):
|
|
"""Score should be near 100 when all files present, no TBDs, no low-conf."""
|
|
from rdb_discovery.context_status import compute_health_score
|
|
|
|
expected = [{"path": "a", "status": "present", "size": "0"} for _ in range(18)]
|
|
score = compute_health_score(expected, tbd_sections=0, low_conf_count=0)
|
|
# 45 (files) + 30 (no TBD) + 15 (no low-conf) = 90, discovery bonus later adds to it
|
|
assert score >= 90
|
|
|
|
def test_score_degrades_with_missing_files(self):
|
|
"""Score should decrease as files are missing."""
|
|
from rdb_discovery.context_status import compute_health_score
|
|
|
|
full = [
|
|
{"path": f"file{i}", "status": "present", "size": "0"} for i in range(18)
|
|
]
|
|
score_full = compute_health_score(full, tbd_sections=0, low_conf_count=0)
|
|
|
|
partial = [
|
|
{"path": "a", "status": "present", "size": "0"},
|
|
{"path": "b", "status": "missing", "size": "0"},
|
|
{"path": "c", "status": "missing", "size": "0"},
|
|
] + [
|
|
{"path": f"file{i}", "status": "present", "size": "0"} for i in range(15)
|
|
]
|
|
score_partial = compute_health_score(partial, tbd_sections=0, low_conf_count=0)
|
|
|
|
assert score_full > score_partial
|
|
|
|
def test_score_degrades_with_tbd_sections(self):
|
|
"""Score should decrease as TBD sections increase."""
|
|
from rdb_discovery.context_status import compute_health_score
|
|
|
|
expected = [{"path": "a", "status": "present", "size": "0"} for _ in range(18)]
|
|
score_0 = compute_health_score(expected, tbd_sections=0, low_conf_count=0)
|
|
score_5 = compute_health_score(expected, tbd_sections=5, low_conf_count=0)
|
|
score_20 = compute_health_score(expected, tbd_sections=20, low_conf_count=0)
|
|
|
|
assert score_0 > score_5
|
|
assert score_5 > score_20
|
|
# At 20+ TBDs, TBD score component is 0
|
|
assert score_20 <= score_5 - 15
|
|
|
|
|
|
class TestContextStatus:
|
|
"""Tests for the main context_status function."""
|
|
|
|
def test_returns_score(self, tmp_project):
|
|
"""context_status should return a score in the report."""
|
|
from rdb_discovery.context_status import context_status
|
|
|
|
report = context_status(tmp_project)
|
|
assert "score" in report
|
|
assert 0 <= report["score"] <= 110 # allows for discovery bonus
|
|
|
|
def test_returns_summary(self, tmp_project):
|
|
"""Report should contain a summary dict."""
|
|
from rdb_discovery.context_status import context_status
|
|
|
|
report = context_status(tmp_project)
|
|
assert "summary" in report
|
|
assert "total_expected" in report["summary"]
|
|
assert "missing" in report["summary"]
|
|
|
|
def test_returns_expected_files(self, tmp_project):
|
|
"""Report should list all expected files."""
|
|
from rdb_discovery.context_status import context_status
|
|
|
|
report = context_status(tmp_project)
|
|
assert len(report["expected_files"]) == 18
|
|
|
|
def test_returns_tbd_sections(self, tmp_project):
|
|
"""Report should include TBD section findings."""
|
|
from rdb_discovery.context_status import context_status
|
|
|
|
report = context_status(tmp_project)
|
|
assert "tbd_sections" in report
|
|
assert len(report["tbd_sections"]) > 0
|
|
|
|
def test_returns_low_confidence(self, tmp_project):
|
|
"""Report should include low-confidence answers."""
|
|
from rdb_discovery.context_status import context_status
|
|
|
|
report = context_status(tmp_project)
|
|
assert "low_confidence" in report
|
|
assert len(report["low_confidence"]) == 1
|
|
|
|
|
|
class TestCLICommand:
|
|
"""Tests that the CLI 'context-status' command is available and works."""
|
|
|
|
def test_context_status_command_exists(self):
|
|
"""The 'rdb context-status' command should be registered."""
|
|
from typer.testing import CliRunner
|
|
from rdb_discovery.cli import app
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["--help"])
|
|
assert result.exit_code == 0
|
|
assert "context-status" in result.output.lower()
|
|
|
|
def test_context_status_runs_successfully(self):
|
|
"""The CLI command should exit cleanly."""
|
|
from typer.testing import CliRunner
|
|
from rdb_discovery.cli import app
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["context-status"], catch_exceptions=False)
|
|
assert result.exit_code == 0
|
|
|
|
def test_context_status_output_contains_score(self):
|
|
"""The output should contain the health score."""
|
|
from typer.testing import CliRunner
|
|
from rdb_discovery.cli import app
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["context-status"], catch_exceptions=False)
|
|
assert result.exit_code == 0
|
|
# Score is in format like "92.5/100"
|
|
assert "/100" in result.output
|
|
|
|
def test_context_status_reports_missing_files(self):
|
|
"""Missing files should be detected by the core function."""
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = Path(td) / "proj"
|
|
root.mkdir()
|
|
(root / "context").mkdir()
|
|
# No context files created — everything missing
|
|
|
|
from rdb_discovery.context_status import _check_expected_files
|
|
|
|
results = _check_expected_files(root)
|
|
paths_by_status = {r["path"]: r["status"] for r in results}
|
|
assert paths_by_status.get("TASKS.md") == "missing"
|
|
|
|
def test_context_status_reports_tbd_sections(self):
|
|
"""TBD placeholders should be detected by the core function."""
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = Path(td) / "proj"
|
|
root.mkdir()
|
|
(root / "context").mkdir()
|
|
# Write a file with TBD placeholder
|
|
(root / "context" / "product-brief.md").write_text(
|
|
"# Product Brief\n\n## Problem\n\nTBD\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
from rdb_discovery.context_status import _check_tbd_sections
|
|
|
|
findings = _check_tbd_sections(root)
|
|
assert len(findings) > 0
|
|
paths = {f["file"] for f in findings}
|
|
assert "product-brief.md" in paths
|
|
|
|
def test_context_status_reports_low_confidence(self):
|
|
"""Low-confidence answers should be detected by the core function."""
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = Path(td) / "proj"
|
|
root.mkdir()
|
|
(root / "context").mkdir()
|
|
# Write discovery-log.md with a Low-confidence answer
|
|
(root / "context" / "discovery-log.md").write_text(
|
|
"# Discovery Log\n\n"
|
|
"| ID | Question | Answer | Confidence | Follow-up needed | Linked decision | Linked task | Date |\n"
|
|
"|---|---|---|---|---|---|---|---|\n"
|
|
'| Q-003 | What does success look like? | Not sure yet | Low | Yes | | | 2026-06-03 |\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
from rdb_discovery.context_status import _check_low_confidence
|
|
|
|
low = _check_low_confidence(root)
|
|
assert len(low) == 1
|
|
assert low[0]["id"] == "Q-003"
|
|
|
|
|
|
class TestHealthScoreColor:
|
|
"""Tests for health score color coding."""
|
|
|
|
def test_full_score_is_high(self):
|
|
"""All files present, no TBDs, no low-conf should produce a high score."""
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = Path(td) / "proj"
|
|
root.mkdir()
|
|
(root / "context").mkdir()
|
|
for f in [
|
|
"discovery-log.md", "product-brief.md", "architecture.md",
|
|
"decisions.md", "risks.md", "assumptions.md", "open-questions.md",
|
|
]:
|
|
(root / "context" / f).write_text(f"# {f}\nDone\n", encoding="utf-8")
|
|
for f in ["TASKS.md", "TEST_PLAN.md", "RUN_LOG.md", "PROJECT_STATE.md", "AGENT_HANDOFF.md"]:
|
|
(root / f).write_text(f"# {f}\nDone\n", encoding="utf-8")
|
|
|
|
from rdb_discovery.context_status import context_status
|
|
|
|
report = context_status(root)
|
|
assert report["score"] >= 70
|
|
|
|
def test_empty_project_score_is_below_full(self):
|
|
"""A completely empty project should score well below a full project."""
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = Path(td) / "proj"
|
|
root.mkdir()
|
|
# Don't create any files
|
|
|
|
from rdb_discovery.context_status import context_status
|
|
|
|
report = context_status(root)
|
|
assert report["score"] < 70
|
|
|
|
def test_cli_score_appears_in_output(self):
|
|
"""The CLI should display the numeric score in output."""
|
|
from typer.testing import CliRunner
|
|
from rdb_discovery.cli import app
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["context-status"], catch_exceptions=False)
|
|
assert result.exit_code == 0
|
|
# Score format is like "92.5/100" — digits followed by /100
|
|
assert "/100" in result.output
|