feat(task-020): generate context from discovery answers
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
"""Tests for context file generation from discovery answers."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
# Use a helper to find the project root
|
||||
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 discovery-log.md and context files."""
|
||||
root = tmp_path / "testproject"
|
||||
root.mkdir()
|
||||
|
||||
# Create directory structure
|
||||
(root / "context").mkdir()
|
||||
|
||||
# Write a discovery log 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-003 | What does success look like? | 80% reduction in onboarding time | High | No | | | 2026-06-03 |\n'
|
||||
'| Q-007 | What are the risks? | Scope creep if requirements change frequently | Medium | Yes | | | 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\nTBD\n\n## Minimum Useful Version\n\nTBD\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write architecture.md with TBD placeholders
|
||||
(root / "context" / "architecture.md").write_text(
|
||||
"# Architecture\n\n## Overview\n\nTBD\n\n## Core Components\n\nTBD\n\n## Data Flow\n\nTDB\n\n## External Integrations\n\nTBD\n\n## Deployment Architecture\n\nTBD\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write risks.md with existing table header only
|
||||
(root / "context" / "risks.md").write_text(
|
||||
"# Risks\n\n| ID | Risk | Impact | Mitigation | Status |\n|---|---|---|---|---|\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write assumptions.md with existing table header only
|
||||
(root / "context" / "assumptions.md").write_text(
|
||||
"# Assumptions\n\n| ID | Assumption | Confidence | Validation Needed |\n|---|---|---|---|\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write infrastructure-context.md with existing content
|
||||
(root / "context" / "infrastructure-context.md").write_text(
|
||||
"# Infrastructure Context\n\n## Hosting\n\nTBD — Cloud provider details.\n\n## Environments\n\nTBD — Dev/staging/prod setup.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write development-context.md with existing content (non-TBD)
|
||||
(root / "context" / "development-context.md").write_text(
|
||||
"# Development Context\n\n## Tech Stack\n\nPython 3.12, Typer, Rich.\n\n## IDEs and Editors\n\nVS Code and Cursor.\n\n## Dependencies\n\nPostgreSQL, Docker.\n\n## Build & Test\n\npytest for unit tests.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
class TestGenerateContextFiles:
|
||||
"""Tests for the generate_context_files function."""
|
||||
|
||||
def test_mapping_file_exists(self):
|
||||
"""The mapping document must exist."""
|
||||
root = _get_project_root()
|
||||
mapping_path = root / "context/discovery-context-mapping.md"
|
||||
assert mapping_path.exists()
|
||||
|
||||
def test_mapping_covers_all_10_questions(self):
|
||||
"""All 10 core questions should have mappings."""
|
||||
from rdb_discovery.generate_context import CONTEXT_MAP
|
||||
|
||||
for i in range(1, 11):
|
||||
qid = f"Q-{i:03d}"
|
||||
assert qid in CONTEXT_MAP, f"Missing mapping for {qid}"
|
||||
|
||||
def test_mapping_all_target_files_are_valid(self):
|
||||
"""All target files in the map should exist as templates."""
|
||||
from rdb_discovery.generate_context import CONTEXT_MAP
|
||||
from rdb_discovery.templates import CONTEXT_FILES
|
||||
|
||||
template_paths = set(CONTEXT_FILES.keys()) | {"TASKS.md", "TEST_PLAN.md"}
|
||||
|
||||
for qid, targets in CONTEXT_MAP.items():
|
||||
for file_path, _, _ in targets:
|
||||
# The file should either be a known template or be context/
|
||||
assert any(file_path.startswith(p.rstrip('/').split('/')[-1] if '/' not in p else '')
|
||||
for p in template_paths) or 'context/' in file_path, \
|
||||
f"{qid} maps to unknown file: {file_path}"
|
||||
|
||||
def test_no_discovery_answers_returns_skipped(self, tmp_project):
|
||||
"""When no discovery answers exist, return skipped message."""
|
||||
# Overwrite discovery log with empty content
|
||||
(tmp_project / "context" / "discovery-log.md").write_text(
|
||||
"# Discovery Log\n\n| ID | Question | Answer | Confidence | Follow-up needed | Linked decision | Linked task | Date |\n|---|---|---|---|---|---|---|---|\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
from rdb_discovery.generate_context import generate_context_files
|
||||
|
||||
result = generate_context_files(tmp_project)
|
||||
assert result["generated"] == []
|
||||
assert len(result["skipped"]) > 0
|
||||
assert "No discovery answers" in result["skipped"][0]
|
||||
|
||||
|
||||
class TestLowConfidenceFiltering:
|
||||
"""Tests for low-confidence answer filtering."""
|
||||
|
||||
def test_low_confidence_answers_are_skipped(self, tmp_project):
|
||||
"""Answers with Low confidence should not generate any context content."""
|
||||
from rdb_discovery.generate_context import generate_context_files
|
||||
|
||||
# Overwrite to have only Low confidence answers
|
||||
(tmp_project / "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? | Some answer | Low | No | | | 2026-06-03 |\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = generate_context_files(tmp_project)
|
||||
assert result["generated"] == []
|
||||
assert "skipped" in result
|
||||
assert len(result["skipped"]) > 0
|
||||
|
||||
|
||||
class TestBodyTextGeneration:
|
||||
"""Tests for body-text section filling."""
|
||||
|
||||
def test_tbd_placeholder_replaced(self, tmp_project):
|
||||
"""TBD placeholders should be replaced with discovery answer."""
|
||||
from rdb_discovery.generate_context import generate_context_files
|
||||
|
||||
result = generate_context_files(tmp_project)
|
||||
assert "context/product-brief.md" in result["generated"]
|
||||
|
||||
content = (tmp_project / "context/product-brief.md").read_text()
|
||||
# Q-001 answer should replace TBD under ## Problem
|
||||
assert "CLI tool for discovery workflow" in content
|
||||
assert "## Problem" in content
|
||||
# The rest of the file must be preserved
|
||||
assert "## Success Criteria" in content
|
||||
assert "## Minimum Useful Version" in content
|
||||
|
||||
|
||||
class TestTableGeneration:
|
||||
"""Tests for table-based section row generation."""
|
||||
|
||||
def test_risks_table_gets_row(self, tmp_project):
|
||||
"""Q-007 (risks) should add a row to risks.md table."""
|
||||
from rdb_discovery.generate_context import generate_context_files
|
||||
|
||||
result = generate_context_files(tmp_project)
|
||||
assert "context/risks.md" in result["generated"]
|
||||
|
||||
content = (tmp_project / "context/risks.md").read_text()
|
||||
assert "| RISK-007 |" in content
|
||||
assert "Scope creep" in content
|
||||
|
||||
|
||||
class TestContentPreservation:
|
||||
"""Tests that existing content is preserved."""
|
||||
|
||||
def test_existing_development_context_preserved(self, tmp_project):
|
||||
"""Existing non-TBD content should be preserved and new content appended."""
|
||||
from rdb_discovery.generate_context import generate_context_files
|
||||
|
||||
generate_context_files(tmp_project)
|
||||
|
||||
content = (tmp_project / "context/development-context.md").read_text()
|
||||
assert "Python 3.12, Typer, Rich." in content
|
||||
assert "VS Code and Cursor." in content
|
||||
|
||||
|
||||
class TestContextMapCompleteness:
|
||||
"""Tests for mapping document completeness."""
|
||||
|
||||
def test_all_10_questions_mapped(self):
|
||||
"""Every core question should have a mapping entry."""
|
||||
from rdb_discovery.generate_context import CONTEXT_MAP
|
||||
|
||||
expected_ids = {f"Q-{i:03d}" for i in range(1, 11)}
|
||||
mapped_ids = set(CONTEXT_MAP.keys())
|
||||
assert expected_ids == mapped_ids, f"Missing mappings: {expected_ids - mapped_ids}"
|
||||
|
||||
def test_each_question_has_at_least_one_target(self):
|
||||
"""Every question mapping should target at least one file."""
|
||||
from rdb_discovery.generate_context import CONTEXT_MAP
|
||||
|
||||
for qid, targets in CONTEXT_MAP.items():
|
||||
assert len(targets) >= 1, f"{qid} has no target files"
|
||||
for file_path, section_name, fmt in targets:
|
||||
assert file_path, f"{qid}: empty file path"
|
||||
assert fmt in ("body", "table", "append"), f"{qid}: invalid format {fmt}"
|
||||
|
||||
|
||||
class TestCLICommand:
|
||||
"""Tests that the CLI 'generate' command is available."""
|
||||
|
||||
def test_generate_command_exists(self):
|
||||
"""The 'rdb generate' command should be registered in the CLI."""
|
||||
from typer.testing import CliRunner
|
||||
from rdb_discovery.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "generate" in result.output.lower() or "-g" in result.output.lower()
|
||||
|
||||
def test_generate_command_with_empty_discovery(self):
|
||||
"""The CLI generate command handles empty discovery gracefully."""
|
||||
from typer.testing import CliRunner
|
||||
from rdb_discovery.cli import app
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td) / "proj"
|
||||
root.mkdir()
|
||||
(root / "context").mkdir()
|
||||
(root / "context" / "discovery-log.md").write_text(
|
||||
"# Discovery Log\n\n| ID | Question |\n|---|---|\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["generate"], catch_exceptions=False)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_generate_creates_missing_files(self):
|
||||
"""Generate should create context files that don't yet exist."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
tmp_path = Path(tempfile.mkdtemp())
|
||||
root = tmp_path / "testproject"
|
||||
root.mkdir()
|
||||
(root / "context").mkdir()
|
||||
|
||||
# Write only discovery-log.md — no context files
|
||||
(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-007 | What are the risks? | Some risk description | High | No | | | 2026-06-03 |\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from rdb_discovery.generate_context import generate_context_files
|
||||
|
||||
result = generate_context_files(root)
|
||||
assert "context/risks.md" in result["generated"]
|
||||
assert (root / "context" / "risks.md").exists()
|
||||
|
||||
shutil.rmtree(tmp_path)
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""End-to-end integration tests for the full generation pipeline."""
|
||||
|
||||
def test_full_generation_flow(self, tmp_project):
|
||||
"""All mapped answers are written to correct files with correct confidence filtering."""
|
||||
from rdb_discovery.generate_context import generate_context_files
|
||||
|
||||
result = generate_context_files(tmp_project)
|
||||
|
||||
# High/Medium answers should be generated
|
||||
assert "context/product-brief.md" in result["generated"]
|
||||
assert "context/project-brief.md" in result["generated"]
|
||||
assert "context/risks.md" in result["generated"]
|
||||
assert "context/assumptions.md" not in result["generated"] # Q-008 is Low confidence
|
||||
|
||||
# Check that product-brief has filled sections
|
||||
content = (tmp_project / "context/product-brief.md").read_text()
|
||||
assert "CLI tool for discovery workflow" in content
|
||||
assert "Internal developers and small dev teams" in content
|
||||
|
||||
# Check that risks.md got a table row
|
||||
risks = (tmp_project / "context/risks.md").read_text()
|
||||
assert "| RISK-007 |" in risks
|
||||
assert "Scope creep" in risks
|
||||
|
||||
def test_preserves_existing_infrastructure_content(self, tmp_project):
|
||||
"""Existing content in infrastructure-context.md is preserved."""
|
||||
from rdb_discovery.generate_context import generate_context_files
|
||||
|
||||
original = (tmp_project / "context/infrastructure-context.md").read_text()
|
||||
|
||||
generate_context_files(tmp_project)
|
||||
|
||||
result = (tmp_project / "context/infrastructure-context.md").read_text()
|
||||
assert "Cloud provider details" in result # Original content preserved
|
||||
Reference in New Issue
Block a user