396 lines
10 KiB
Python
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"])
|