development-hub/tests/test_parser_roundtrip.py

396 lines
10 KiB
Python

"""Round-trip tests for parsers.
These tests verify that parse -> save -> parse produces identical data.
This ensures no data loss during save operations.
"""
import tempfile
from pathlib import Path
import pytest
from development_hub.parsers.todos_parser import TodosParser
from development_hub.parsers.goals_parser import GoalsParser, MilestonesParser, GoalsSaver
from development_hub.models.todo import Todo, TodoList
from development_hub.models.goal import Goal, GoalList, Milestone, Deliverable, MilestoneStatus, DeliverableStatus
class TestTodosRoundtrip:
"""Test TodosParser round-trip preservation."""
def test_simple_todos_roundtrip(self, tmp_path):
"""Parse -> save -> parse should produce identical data."""
todos_file = tmp_path / "todos.md"
original_content = """---
type: todos
project: test-project
updated: 2026-01-08
---
# TODOs
## Active Tasks
### High Priority
- [ ] Important task #high
- [ ] Another important task #high @M1
### Medium Priority
- [ ] Medium task #medium
- [x] Completed medium task #medium (2026-01-07)
### Low Priority
- [ ] Low priority task #low
## Completed
- [x] Done task #high (2026-01-06)
- [x] Another done #medium (2026-01-05)
"""
todos_file.write_text(original_content)
# First parse
parser1 = TodosParser(todos_file)
todo_list1 = parser1.parse()
# Save
parser1.save(todo_list1)
# Second parse
parser2 = TodosParser(todos_file)
todo_list2 = parser2.parse()
# Compare
assert len(todo_list1.all_todos) == len(todo_list2.all_todos)
assert len(todo_list1.completed) == len(todo_list2.completed)
# Compare individual todos
for t1, t2 in zip(todo_list1.all_todos, todo_list2.all_todos):
assert t1.text == t2.text
assert t1.priority == t2.priority
assert t1.completed == t2.completed
assert t1.milestone == t2.milestone
def test_todos_with_milestones_roundtrip(self, tmp_path):
"""Milestone tags should be preserved."""
todos_file = tmp_path / "todos.md"
original_content = """---
type: todos
project: test
---
# TODOs
## Active Tasks
### High Priority
- [ ] Task with milestone @M1 #high
- [ ] Task with M2 @M2 #high
### Medium Priority
### Low Priority
## Completed
"""
todos_file.write_text(original_content)
parser1 = TodosParser(todos_file)
todo_list1 = parser1.parse()
# Verify milestones parsed
high_todos = [t for t in todo_list1.all_todos if t.priority == "high"]
assert any(t.milestone == "M1" for t in high_todos)
assert any(t.milestone == "M2" for t in high_todos)
# Save and reparse
parser1.save(todo_list1)
parser2 = TodosParser(todos_file)
todo_list2 = parser2.parse()
# Verify milestones preserved
high_todos2 = [t for t in todo_list2.all_todos if t.priority == "high"]
assert any(t.milestone == "M1" for t in high_todos2)
assert any(t.milestone == "M2" for t in high_todos2)
class TestGoalsRoundtrip:
"""Test GoalsParser round-trip preservation."""
def test_goals_roundtrip(self, tmp_path):
"""Goals should be preserved across parse -> save -> parse."""
goals_file = tmp_path / "goals.md"
original_content = """---
type: goals
project: test-project
updated: 2026-01-08
---
# Goals
## Active
- [ ] Incomplete goal #high
- [~] Partial goal #medium
- [x] Completed goal #low
## Future
- [ ] Future goal 1 #medium
- [ ] Future goal 2 #low
## Non-Goals
- [ ] Not doing this #medium
- [x] Confirmed non-goal #high
"""
goals_file.write_text(original_content)
# First parse
parser1 = GoalsParser(goals_file)
goal_list1 = parser1.parse()
# Save using GoalsSaver
saver = GoalsSaver(goals_file, parser1.frontmatter)
saver.save(goal_list1)
# Second parse
parser2 = GoalsParser(goals_file)
goal_list2 = parser2.parse()
# Compare counts
assert len(goal_list1.active) == len(goal_list2.active)
assert len(goal_list1.future) == len(goal_list2.future)
assert len(goal_list1.non_goals) == len(goal_list2.non_goals)
# Compare active goals
for g1, g2 in zip(goal_list1.active, goal_list2.active):
assert g1.text == g2.text
assert g1.completed == g2.completed
assert g1.partial == g2.partial
assert g1.priority == g2.priority
def test_three_state_goals_roundtrip(self, tmp_path):
"""Three-state goals ([ ], [~], [x]) should be preserved."""
goals_file = tmp_path / "goals.md"
original_content = """---
type: goals
project: test
---
# Goals
## Active
- [ ] Not started #medium
- [~] In progress #high
- [x] Done #low
"""
goals_file.write_text(original_content)
parser1 = GoalsParser(goals_file)
goal_list1 = parser1.parse()
# Verify states parsed correctly
assert goal_list1.active[0].completed == False
assert goal_list1.active[0].partial == False
assert goal_list1.active[1].completed == False
assert goal_list1.active[1].partial == True
assert goal_list1.active[2].completed == True
assert goal_list1.active[2].partial == False
# Save and reparse
saver = GoalsSaver(goals_file, parser1.frontmatter)
saver.save(goal_list1)
parser2 = GoalsParser(goals_file)
goal_list2 = parser2.parse()
# Verify states preserved
assert goal_list2.active[0].completed == False
assert goal_list2.active[0].partial == False
assert goal_list2.active[1].completed == False
assert goal_list2.active[1].partial == True
assert goal_list2.active[2].completed == True
assert goal_list2.active[2].partial == False
class TestMilestonesRoundtrip:
"""Test MilestonesParser round-trip preservation."""
def test_milestones_roundtrip(self, tmp_path):
"""Milestones should be preserved across parse -> save -> parse."""
milestones_file = tmp_path / "milestones.md"
original_content = """---
type: milestones
project: test-project
updated: 2026-01-08
---
# Milestones
#### M1: First Milestone
**Target**: January 2026
**Status**: In Progress (50%)
First milestone description.
| Deliverable | Status |
|-------------|--------|
| Task A | Done |
| Task B | In Progress |
| Task C | Not Started |
---
#### M2: Second Milestone
**Target**: February 2026
**Status**: Not Started
Second milestone description.
| Deliverable | Status |
|-------------|--------|
| Feature X | Not Started |
| Feature Y | Not Started |
---
"""
milestones_file.write_text(original_content)
# First parse
parser1 = MilestonesParser(milestones_file)
milestones1 = parser1.parse()
# Save
parser1.save(milestones1)
# Second parse
parser2 = MilestonesParser(milestones_file)
milestones2 = parser2.parse()
# Compare counts
assert len(milestones1) == len(milestones2)
# Compare individual milestones
for m1, m2 in zip(milestones1, milestones2):
assert m1.id == m2.id
assert m1.name == m2.name
assert m1.target == m2.target
assert m1.status == m2.status
assert len(m1.deliverables) == len(m2.deliverables)
# Compare deliverables
for d1, d2 in zip(m1.deliverables, m2.deliverables):
assert d1.name == d2.name
assert d1.status == d2.status
def test_milestone_status_roundtrip(self, tmp_path):
"""Milestone statuses should be preserved."""
milestones_file = tmp_path / "milestones.md"
original_content = """---
type: milestones
project: test
---
# Milestones
#### M1: Complete
**Target**: Dec 2025
**Status**: Completed (100%)
---
#### M2: In Progress
**Target**: Jan 2026
**Status**: In Progress (75%)
---
#### M3: Planning
**Target**: Feb 2026
**Status**: Planning (10%)
---
#### M4: Not Started
**Target**: Q2 2026
**Status**: Not Started
---
"""
milestones_file.write_text(original_content)
parser1 = MilestonesParser(milestones_file)
milestones1 = parser1.parse()
# Create lookup by ID
by_id1 = {m.id: m for m in milestones1}
# Verify statuses parsed
assert by_id1["M1"].status == MilestoneStatus.COMPLETE
assert by_id1["M2"].status == MilestoneStatus.IN_PROGRESS
assert by_id1["M3"].status == MilestoneStatus.PLANNING
assert by_id1["M4"].status == MilestoneStatus.NOT_STARTED
# Save and reparse
parser1.save(milestones1)
parser2 = MilestonesParser(milestones_file)
milestones2 = parser2.parse()
# Create lookup by ID
by_id2 = {m.id: m for m in milestones2}
# Verify statuses preserved (order may change due to Active/Completed sections)
assert by_id2["M1"].status == MilestoneStatus.COMPLETE
assert by_id2["M2"].status == MilestoneStatus.IN_PROGRESS
assert by_id2["M3"].status == MilestoneStatus.PLANNING
assert by_id2["M4"].status == MilestoneStatus.NOT_STARTED
class TestAtomicWrite:
"""Test atomic write functionality."""
def test_atomic_write_creates_file(self, tmp_path):
"""atomic_write should create file if it doesn't exist."""
from development_hub.parsers.base import atomic_write
test_file = tmp_path / "new_file.md"
content = "# Test Content\n\nSome text here."
atomic_write(test_file, content)
assert test_file.exists()
assert test_file.read_text() == content
def test_atomic_write_overwrites_file(self, tmp_path):
"""atomic_write should safely overwrite existing file."""
from development_hub.parsers.base import atomic_write
test_file = tmp_path / "existing.md"
test_file.write_text("Original content")
new_content = "New content here"
atomic_write(test_file, new_content)
assert test_file.read_text() == new_content
def test_atomic_write_no_temp_files_left(self, tmp_path):
"""atomic_write should not leave temp files after success."""
from development_hub.parsers.base import atomic_write
test_file = tmp_path / "test.md"
atomic_write(test_file, "content")
# Check no temp files remain
temp_files = list(tmp_path.glob(".test.md.*"))
assert len(temp_files) == 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])