Files
markitect-tool/tests/test_contract_framework.py

337 lines
9.4 KiB
Python

from pathlib import Path
from click.testing import CliRunner
from markitect_tool.cli import main
from markitect_tool.contract import (
check_markdown_file,
collect_metrics,
load_contract_file,
validate_contract,
)
from markitect_tool.core import parse_markdown
EXAMPLE_CASES = [
(
"adr",
Path("examples/contracts/adr.contract.md"),
Path("examples/documents/adr-valid.md"),
Path("examples/documents/adr-invalid.md"),
{
"contract.field.missing",
"contract.metric.too_low",
"contract.assertion.contains_any_missing",
"contract.section.missing",
"contract.section.recommended_missing",
"contract.section.forbidden",
},
),
(
"prd-frs",
Path("examples/contracts/prd-frs.contract.md"),
Path("examples/documents/prd-frs-valid.md"),
Path("examples/documents/prd-frs-invalid.md"),
{
"contract.field.missing",
"contract.metric.too_low",
"contract.assertion.contains_any_missing",
"contract.section.missing",
"contract.section.recommended_missing",
"contract.section.discouraged",
},
),
(
"workplan",
Path("examples/contracts/workplan.contract.md"),
Path("examples/documents/workplan-valid.md"),
Path("examples/documents/workplan-invalid.md"),
{
"contract.field.missing",
"contract.field.enum",
"contract.assertion.contains_missing",
"contract.section.recommended_missing",
},
),
(
"business-letter",
Path("examples/contracts/business-letter.contract.md"),
Path("examples/documents/business-letter-valid.md"),
Path("examples/documents/business-letter-invalid.md"),
{
"contract.field.missing",
"contract.section.missing",
"contract.metric.too_low",
},
),
(
"concept-note",
Path("examples/contracts/concept-note.contract.md"),
Path("examples/documents/concept-note-valid.md"),
Path("examples/documents/concept-note-invalid.md"),
{
"contract.field.enum",
"contract.metric.too_low",
"contract.section.missing",
},
),
]
CONTRACT_TEXT = """---
title: ADR Contract
version: "1.0"
---
# ADR Contract
```yaml contract
id: adr-contract-v1
document:
type: adr
title: Architecture Decision Record
fields:
status:
type: string
required: true
enum: [proposed, accepted, superseded]
metrics:
document:
words:
min: 12
max: 240
severity: warning
sections:
- id: context
title: Context
presence: required
level: 2
order:
before: decision
metrics:
words:
min: 4
max: 80
severity: warning
assertions:
- id: context-names-problem
contains_any: [problem, motivation]
severity: warning
guidance: Explain why the decision exists.
- id: decision
title: Decision
presence: required
level: 2
assertions:
- id: decision-commits
matches: "\\\\b(choose|adopt|use|will)\\\\b"
severity: error
guidance: State the actual decision, not only background.
- id: consequences
title: Consequences
presence: recommended
level: 2
- id: deprecated
title: Deprecated Approach
presence: forbidden
```
"""
VALID_ADR = """---
document_type: adr
status: accepted
---
# Use Markdown Contracts
## Context
The problem is that plain heading counts do not explain whether content is useful.
## Decision
We will use a markdown-native document contract with deterministic diagnostics.
## Consequences
The tool can check author intent before generation or review work continues.
"""
INVALID_ADR = """---
document_type: adr
---
# Weak ADR
## Context
This is short.
## Deprecated Approach
This section should not be here.
"""
def test_load_contract_file_extracts_markdown_yaml_contract(tmp_path: Path):
contract_file = tmp_path / "adr.contract.md"
contract_file.write_text(CONTRACT_TEXT, encoding="utf-8")
contract = load_contract_file(contract_file)
assert contract.id == "adr-contract-v1"
assert contract.document_type == "adr"
assert contract.fields[0].id == "status"
assert [section.id for section in contract.sections] == [
"context",
"decision",
"consequences",
"deprecated",
]
def test_validate_contract_accepts_complete_contract(tmp_path: Path):
contract_file = tmp_path / "adr.contract.md"
contract_file.write_text(CONTRACT_TEXT, encoding="utf-8")
result = validate_contract(load_contract_file(contract_file))
assert result.valid is True
assert result.diagnostics == []
def test_validate_contract_reports_bad_regex(tmp_path: Path):
contract_file = tmp_path / "bad.contract.md"
contract_file.write_text(
CONTRACT_TEXT.replace("\\\\b(choose|adopt|use|will)\\\\b", "[bad"),
encoding="utf-8",
)
result = validate_contract(load_contract_file(contract_file))
assert result.valid is False
assert result.diagnostics[0].code == "contract.regex.invalid"
def test_check_markdown_file_accepts_valid_document(tmp_path: Path):
contract_file = tmp_path / "adr.contract.md"
document_file = tmp_path / "adr.md"
contract_file.write_text(CONTRACT_TEXT, encoding="utf-8")
document_file.write_text(VALID_ADR, encoding="utf-8")
result = check_markdown_file(document_file, contract_file)
assert result.valid is True
assert result.diagnostics == []
assert result.metrics["document"]["sections"] == 4
def test_check_markdown_file_reports_practical_failures(tmp_path: Path):
contract_file = tmp_path / "adr.contract.md"
document_file = tmp_path / "adr.md"
contract_file.write_text(CONTRACT_TEXT, encoding="utf-8")
document_file.write_text(INVALID_ADR, encoding="utf-8")
result = check_markdown_file(document_file, contract_file)
codes = {diagnostic.code for diagnostic in result.diagnostics}
assert result.valid is False
assert "contract.field.missing" in codes
assert "contract.section.missing" in codes
assert "contract.section.forbidden" in codes
assert "contract.metric.too_low" in codes
def test_check_markdown_file_keeps_warning_only_results_valid(tmp_path: Path):
contract_file = tmp_path / "adr.contract.md"
document_file = tmp_path / "adr.md"
contract_file.write_text(CONTRACT_TEXT, encoding="utf-8")
document_file.write_text(
VALID_ADR.replace("The problem is", "The situation is"),
encoding="utf-8",
)
result = check_markdown_file(document_file, contract_file)
assert result.valid is True
assert [diagnostic.code for diagnostic in result.diagnostics] == [
"contract.assertion.contains_any_missing"
]
assert result.diagnostics[0].severity == "warning"
def test_collect_metrics_counts_document_and_sections():
document = parse_markdown(VALID_ADR)
metrics = collect_metrics(document)
assert metrics.words > 20
assert metrics.sections == 4
context_metrics = next(
section for section in metrics.section_metrics if section.heading == "Context"
)
assert context_metrics.words >= 10
def test_mkt_contract_validate(tmp_path: Path):
contract_file = tmp_path / "adr.contract.md"
contract_file.write_text(CONTRACT_TEXT, encoding="utf-8")
result = CliRunner().invoke(main, ["contract", "validate", str(contract_file)])
assert result.exit_code == 0
assert "valid" in result.output
def test_mkt_contract_check_reports_invalid_document(tmp_path: Path):
contract_file = tmp_path / "adr.contract.md"
document_file = tmp_path / "adr.md"
contract_file.write_text(CONTRACT_TEXT, encoding="utf-8")
document_file.write_text(INVALID_ADR, encoding="utf-8")
result = CliRunner().invoke(
main, ["contract", "check", str(document_file), "--contract", str(contract_file)]
)
assert result.exit_code == 1
assert "contract.section.missing" in result.output
assert "guidance" in result.output
def test_mkt_metrics_outputs_text(tmp_path: Path):
document_file = tmp_path / "adr.md"
document_file.write_text(VALID_ADR, encoding="utf-8")
result = CliRunner().invoke(main, ["metrics", str(document_file)])
assert result.exit_code == 0
assert "document" in result.output
assert "words" in result.output
assert "Context" in result.output
def test_example_contracts_validate():
for _name, contract_path, _valid_path, _invalid_path, _expected in EXAMPLE_CASES:
result = validate_contract(load_contract_file(contract_path))
assert result.valid is True
def test_example_valid_documents_have_no_error_diagnostics():
for name, contract_path, valid_path, _invalid_path, _expected in EXAMPLE_CASES:
result = check_markdown_file(valid_path, contract_path)
assert result.valid is True, name
assert all(diagnostic.severity != "error" for diagnostic in result.diagnostics)
def test_example_invalid_documents_report_expected_diagnostics():
for name, contract_path, _valid_path, invalid_path, expected in EXAMPLE_CASES:
result = check_markdown_file(invalid_path, contract_path)
codes = {diagnostic.code for diagnostic in result.diagnostics}
assert result.valid is False, name
assert expected <= codes