feat: implement XER MCP Server with 9 schedule query tools
Implement complete MCP server for parsing Primavera P6 XER files and exposing schedule data through MCP tools. All 4 user stories complete. Tools implemented: - load_xer: Parse XER files into SQLite database - list_activities: Query activities with pagination and filtering - get_activity: Get activity details by ID - list_relationships: Query activity dependencies - get_predecessors/get_successors: Query activity relationships - get_project_summary: Project overview with counts - list_milestones: Query milestone activities - get_critical_path: Query driving path activities Features: - Tab-delimited XER format parsing with pluggable table handlers - In-memory SQLite database for fast queries - Pagination with 100-item default limit - Multi-project file support with project selection - ISO8601 date formatting - NO_FILE_LOADED error handling for all query tools Test coverage: 81 tests (contract, integration, unit)
This commit is contained in:
1
tests/contract/__init__.py
Normal file
1
tests/contract/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Contract tests for MCP tool interfaces."""
|
||||
89
tests/contract/test_get_activity.py
Normal file
89
tests/contract/test_get_activity.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Contract tests for get_activity MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
yield
|
||||
db.clear()
|
||||
|
||||
|
||||
class TestGetActivityContract:
|
||||
"""Contract tests verifying get_activity tool interface matches spec."""
|
||||
|
||||
async def test_get_activity_returns_details(self, sample_xer_single_project: Path) -> None:
|
||||
"""get_activity returns complete activity details."""
|
||||
from xer_mcp.tools.get_activity import get_activity
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_activity(activity_id="2002")
|
||||
|
||||
assert result["task_id"] == "2002"
|
||||
assert result["task_code"] == "A1010"
|
||||
assert result["task_name"] == "Site Preparation"
|
||||
assert result["task_type"] == "TT_Task"
|
||||
assert "target_start_date" in result
|
||||
assert "target_end_date" in result
|
||||
assert "wbs_id" in result
|
||||
assert "predecessor_count" in result
|
||||
assert "successor_count" in result
|
||||
|
||||
async def test_get_activity_includes_wbs_name(self, sample_xer_single_project: Path) -> None:
|
||||
"""get_activity includes WBS name from lookup."""
|
||||
from xer_mcp.tools.get_activity import get_activity
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_activity(activity_id="2002")
|
||||
|
||||
assert "wbs_name" in result
|
||||
|
||||
async def test_get_activity_includes_relationship_counts(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_activity includes predecessor and successor counts."""
|
||||
from xer_mcp.tools.get_activity import get_activity
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
# A1010 (2002) has 1 predecessor (A1000) and 2 successors (A1020, A1030)
|
||||
result = await get_activity(activity_id="2002")
|
||||
|
||||
assert result["predecessor_count"] == 1
|
||||
assert result["successor_count"] == 2
|
||||
|
||||
async def test_get_activity_not_found_returns_error(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_activity with invalid ID returns ACTIVITY_NOT_FOUND error."""
|
||||
from xer_mcp.tools.get_activity import get_activity
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_activity(activity_id="nonexistent")
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "ACTIVITY_NOT_FOUND"
|
||||
|
||||
async def test_get_activity_no_file_loaded_returns_error(self) -> None:
|
||||
"""get_activity without loaded file returns NO_FILE_LOADED error."""
|
||||
from xer_mcp.server import set_file_loaded
|
||||
from xer_mcp.tools.get_activity import get_activity
|
||||
|
||||
set_file_loaded(False)
|
||||
result = await get_activity(activity_id="2002")
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "NO_FILE_LOADED"
|
||||
91
tests/contract/test_get_critical_path.py
Normal file
91
tests/contract/test_get_critical_path.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Contract tests for get_critical_path MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
yield
|
||||
db.clear()
|
||||
|
||||
|
||||
class TestGetCriticalPathContract:
|
||||
"""Contract tests verifying get_critical_path tool interface."""
|
||||
|
||||
async def test_get_critical_path_returns_critical_activities(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_critical_path returns activities with driving_path_flag set."""
|
||||
from xer_mcp.tools.get_critical_path import get_critical_path
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_critical_path()
|
||||
|
||||
assert "critical_activities" in result
|
||||
assert len(result["critical_activities"]) == 4
|
||||
|
||||
async def test_get_critical_path_includes_expected_fields(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_critical_path returns activities with required fields."""
|
||||
from xer_mcp.tools.get_critical_path import get_critical_path
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_critical_path()
|
||||
|
||||
activity = result["critical_activities"][0]
|
||||
assert "task_id" in activity
|
||||
assert "task_code" in activity
|
||||
assert "task_name" in activity
|
||||
assert "target_start_date" in activity
|
||||
assert "target_end_date" in activity
|
||||
assert "total_float_hr_cnt" in activity
|
||||
|
||||
async def test_get_critical_path_ordered_by_date(self, sample_xer_single_project: Path) -> None:
|
||||
"""get_critical_path returns activities ordered by start date."""
|
||||
from xer_mcp.tools.get_critical_path import get_critical_path
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_critical_path()
|
||||
|
||||
activities = result["critical_activities"]
|
||||
for i in range(len(activities) - 1):
|
||||
assert activities[i]["target_start_date"] <= activities[i + 1]["target_start_date"]
|
||||
|
||||
async def test_get_critical_path_excludes_non_critical(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_critical_path excludes activities not on critical path."""
|
||||
from xer_mcp.tools.get_critical_path import get_critical_path
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_critical_path()
|
||||
|
||||
# A1020 "Foundation Work" has driving_path_flag = N
|
||||
activity_names = [a["task_name"] for a in result["critical_activities"]]
|
||||
assert "Foundation Work" not in activity_names
|
||||
|
||||
async def test_get_critical_path_no_file_loaded_returns_error(self) -> None:
|
||||
"""get_critical_path without loaded file returns NO_FILE_LOADED error."""
|
||||
from xer_mcp.server import set_file_loaded
|
||||
from xer_mcp.tools.get_critical_path import get_critical_path
|
||||
|
||||
set_file_loaded(False)
|
||||
result = await get_critical_path()
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "NO_FILE_LOADED"
|
||||
77
tests/contract/test_get_predecessors.py
Normal file
77
tests/contract/test_get_predecessors.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Contract tests for get_predecessors MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
yield
|
||||
db.clear()
|
||||
|
||||
|
||||
class TestGetPredecessorsContract:
|
||||
"""Contract tests verifying get_predecessors tool interface."""
|
||||
|
||||
async def test_get_predecessors_returns_list(self, sample_xer_single_project: Path) -> None:
|
||||
"""get_predecessors returns predecessor activities."""
|
||||
from xer_mcp.tools.get_predecessors import get_predecessors
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
# A1010 (2002) has one predecessor: A1000 (2001)
|
||||
result = await get_predecessors(activity_id="2002")
|
||||
|
||||
assert "activity_id" in result
|
||||
assert result["activity_id"] == "2002"
|
||||
assert "predecessors" in result
|
||||
assert len(result["predecessors"]) == 1
|
||||
assert result["predecessors"][0]["task_id"] == "2001"
|
||||
|
||||
async def test_get_predecessors_includes_relationship_details(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_predecessors includes relationship type and lag."""
|
||||
from xer_mcp.tools.get_predecessors import get_predecessors
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_predecessors(activity_id="2002")
|
||||
|
||||
pred = result["predecessors"][0]
|
||||
assert "relationship_type" in pred
|
||||
assert "lag_hr_cnt" in pred
|
||||
assert pred["relationship_type"] in ["FS", "SS", "FF", "SF"]
|
||||
|
||||
async def test_get_predecessors_empty_list_for_no_predecessors(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_predecessors returns empty list when no predecessors exist."""
|
||||
from xer_mcp.tools.get_predecessors import get_predecessors
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
# A1000 (2001) has no predecessors
|
||||
result = await get_predecessors(activity_id="2001")
|
||||
|
||||
assert "predecessors" in result
|
||||
assert len(result["predecessors"]) == 0
|
||||
|
||||
async def test_get_predecessors_no_file_loaded_returns_error(self) -> None:
|
||||
"""get_predecessors without loaded file returns NO_FILE_LOADED error."""
|
||||
from xer_mcp.server import set_file_loaded
|
||||
from xer_mcp.tools.get_predecessors import get_predecessors
|
||||
|
||||
set_file_loaded(False)
|
||||
result = await get_predecessors(activity_id="2002")
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "NO_FILE_LOADED"
|
||||
88
tests/contract/test_get_project_summary.py
Normal file
88
tests/contract/test_get_project_summary.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Contract tests for get_project_summary MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
yield
|
||||
db.clear()
|
||||
|
||||
|
||||
class TestGetProjectSummaryContract:
|
||||
"""Contract tests verifying get_project_summary tool interface."""
|
||||
|
||||
async def test_get_project_summary_returns_basic_info(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_project_summary returns project name, dates, and activity count."""
|
||||
from xer_mcp.tools.get_project_summary import get_project_summary
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_project_summary()
|
||||
|
||||
assert "project_name" in result
|
||||
assert "plan_start_date" in result
|
||||
assert "plan_end_date" in result
|
||||
assert "activity_count" in result
|
||||
|
||||
async def test_get_project_summary_returns_correct_values(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_project_summary returns correct project values from loaded XER."""
|
||||
from xer_mcp.tools.get_project_summary import get_project_summary
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_project_summary()
|
||||
|
||||
assert result["project_name"] == "Test Project"
|
||||
assert result["activity_count"] == 5
|
||||
|
||||
async def test_get_project_summary_includes_milestone_count(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_project_summary includes count of milestones."""
|
||||
from xer_mcp.tools.get_project_summary import get_project_summary
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_project_summary()
|
||||
|
||||
assert "milestone_count" in result
|
||||
assert result["milestone_count"] == 2
|
||||
|
||||
async def test_get_project_summary_includes_critical_count(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_project_summary includes count of critical path activities."""
|
||||
from xer_mcp.tools.get_project_summary import get_project_summary
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_project_summary()
|
||||
|
||||
assert "critical_activity_count" in result
|
||||
assert result["critical_activity_count"] == 4
|
||||
|
||||
async def test_get_project_summary_no_file_loaded_returns_error(self) -> None:
|
||||
"""get_project_summary without loaded file returns NO_FILE_LOADED error."""
|
||||
from xer_mcp.server import set_file_loaded
|
||||
from xer_mcp.tools.get_project_summary import get_project_summary
|
||||
|
||||
set_file_loaded(False)
|
||||
result = await get_project_summary()
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "NO_FILE_LOADED"
|
||||
76
tests/contract/test_get_successors.py
Normal file
76
tests/contract/test_get_successors.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Contract tests for get_successors MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
yield
|
||||
db.clear()
|
||||
|
||||
|
||||
class TestGetSuccessorsContract:
|
||||
"""Contract tests verifying get_successors tool interface."""
|
||||
|
||||
async def test_get_successors_returns_list(self, sample_xer_single_project: Path) -> None:
|
||||
"""get_successors returns successor activities."""
|
||||
from xer_mcp.tools.get_successors import get_successors
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
# A1010 (2002) has two successors: A1020 (2003) and A1030 (2004)
|
||||
result = await get_successors(activity_id="2002")
|
||||
|
||||
assert "activity_id" in result
|
||||
assert result["activity_id"] == "2002"
|
||||
assert "successors" in result
|
||||
assert len(result["successors"]) == 2
|
||||
|
||||
async def test_get_successors_includes_relationship_details(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_successors includes relationship type and lag."""
|
||||
from xer_mcp.tools.get_successors import get_successors
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_successors(activity_id="2001")
|
||||
|
||||
succ = result["successors"][0]
|
||||
assert "relationship_type" in succ
|
||||
assert "lag_hr_cnt" in succ
|
||||
assert succ["relationship_type"] in ["FS", "SS", "FF", "SF"]
|
||||
|
||||
async def test_get_successors_empty_list_for_no_successors(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_successors returns empty list when no successors exist."""
|
||||
from xer_mcp.tools.get_successors import get_successors
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
# A1040 (2005) has no successors
|
||||
result = await get_successors(activity_id="2005")
|
||||
|
||||
assert "successors" in result
|
||||
assert len(result["successors"]) == 0
|
||||
|
||||
async def test_get_successors_no_file_loaded_returns_error(self) -> None:
|
||||
"""get_successors without loaded file returns NO_FILE_LOADED error."""
|
||||
from xer_mcp.server import set_file_loaded
|
||||
from xer_mcp.tools.get_successors import get_successors
|
||||
|
||||
set_file_loaded(False)
|
||||
result = await get_successors(activity_id="2002")
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "NO_FILE_LOADED"
|
||||
138
tests/contract/test_list_activities.py
Normal file
138
tests/contract/test_list_activities.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Contract tests for list_activities MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
yield
|
||||
db.clear()
|
||||
|
||||
|
||||
class TestListActivitiesContract:
|
||||
"""Contract tests verifying list_activities tool interface matches spec."""
|
||||
|
||||
async def test_list_activities_returns_paginated_results(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""list_activities returns activities with pagination metadata."""
|
||||
from xer_mcp.tools.list_activities import list_activities
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_activities()
|
||||
|
||||
assert "activities" in result
|
||||
assert "pagination" in result
|
||||
assert len(result["activities"]) == 5
|
||||
assert result["pagination"]["total_count"] == 5
|
||||
assert result["pagination"]["offset"] == 0
|
||||
assert result["pagination"]["limit"] == 100
|
||||
assert result["pagination"]["has_more"] is False
|
||||
|
||||
async def test_list_activities_with_limit(self, sample_xer_single_project: Path) -> None:
|
||||
"""list_activities respects limit parameter."""
|
||||
from xer_mcp.tools.list_activities import list_activities
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_activities(limit=2)
|
||||
|
||||
assert len(result["activities"]) == 2
|
||||
assert result["pagination"]["limit"] == 2
|
||||
assert result["pagination"]["has_more"] is True
|
||||
|
||||
async def test_list_activities_with_offset(self, sample_xer_single_project: Path) -> None:
|
||||
"""list_activities respects offset parameter."""
|
||||
from xer_mcp.tools.list_activities import list_activities
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_activities(offset=2, limit=2)
|
||||
|
||||
assert len(result["activities"]) == 2
|
||||
assert result["pagination"]["offset"] == 2
|
||||
|
||||
async def test_list_activities_filter_by_date_range(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""list_activities filters by date range."""
|
||||
from xer_mcp.tools.list_activities import list_activities
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
# Filter to January only
|
||||
result = await list_activities(start_date="2026-01-01", end_date="2026-01-31")
|
||||
|
||||
# Should include activities in January
|
||||
for activity in result["activities"]:
|
||||
assert "2026-01" in activity["target_start_date"]
|
||||
|
||||
async def test_list_activities_filter_by_activity_type(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""list_activities filters by activity type."""
|
||||
from xer_mcp.tools.list_activities import list_activities
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_activities(activity_type="TT_Mile")
|
||||
|
||||
assert len(result["activities"]) == 2
|
||||
for activity in result["activities"]:
|
||||
assert activity["task_type"] == "TT_Mile"
|
||||
|
||||
async def test_list_activities_filter_by_wbs(self, sample_xer_single_project: Path) -> None:
|
||||
"""list_activities filters by WBS ID."""
|
||||
from xer_mcp.tools.list_activities import list_activities
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_activities(wbs_id="101")
|
||||
|
||||
# WBS 101 has 3 activities in the fixture
|
||||
assert len(result["activities"]) == 3
|
||||
|
||||
async def test_list_activities_no_file_loaded_returns_error(self) -> None:
|
||||
"""list_activities without loaded file returns NO_FILE_LOADED error."""
|
||||
from xer_mcp.server import set_file_loaded
|
||||
from xer_mcp.tools.list_activities import list_activities
|
||||
|
||||
set_file_loaded(False)
|
||||
result = await list_activities()
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "NO_FILE_LOADED"
|
||||
|
||||
async def test_list_activities_returns_expected_fields(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""list_activities returns activities with all expected fields."""
|
||||
from xer_mcp.tools.list_activities import list_activities
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_activities()
|
||||
|
||||
activity = result["activities"][0]
|
||||
assert "task_id" in activity
|
||||
assert "task_code" in activity
|
||||
assert "task_name" in activity
|
||||
assert "task_type" in activity
|
||||
assert "target_start_date" in activity
|
||||
assert "target_end_date" in activity
|
||||
assert "status_code" in activity
|
||||
assert "driving_path_flag" in activity
|
||||
89
tests/contract/test_list_milestones.py
Normal file
89
tests/contract/test_list_milestones.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Contract tests for list_milestones MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
yield
|
||||
db.clear()
|
||||
|
||||
|
||||
class TestListMilestonesContract:
|
||||
"""Contract tests verifying list_milestones tool interface."""
|
||||
|
||||
async def test_list_milestones_returns_milestone_activities(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""list_milestones returns only milestone type activities."""
|
||||
from xer_mcp.tools.list_milestones import list_milestones
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_milestones()
|
||||
|
||||
assert "milestones" in result
|
||||
assert len(result["milestones"]) == 2
|
||||
|
||||
async def test_list_milestones_includes_expected_fields(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""list_milestones returns milestones with required fields."""
|
||||
from xer_mcp.tools.list_milestones import list_milestones
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_milestones()
|
||||
|
||||
milestone = result["milestones"][0]
|
||||
assert "task_id" in milestone
|
||||
assert "task_code" in milestone
|
||||
assert "task_name" in milestone
|
||||
assert "target_start_date" in milestone
|
||||
assert "target_end_date" in milestone
|
||||
|
||||
async def test_list_milestones_returns_correct_activities(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""list_milestones returns the expected milestone activities."""
|
||||
from xer_mcp.tools.list_milestones import list_milestones
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_milestones()
|
||||
|
||||
milestone_names = [m["task_name"] for m in result["milestones"]]
|
||||
assert "Project Start" in milestone_names
|
||||
assert "Project Complete" in milestone_names
|
||||
|
||||
async def test_list_milestones_empty_when_no_milestones(self, sample_xer_empty: Path) -> None:
|
||||
"""list_milestones returns empty list when no milestones exist."""
|
||||
from xer_mcp.tools.list_milestones import list_milestones
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_empty))
|
||||
|
||||
result = await list_milestones()
|
||||
|
||||
assert "milestones" in result
|
||||
assert len(result["milestones"]) == 0
|
||||
|
||||
async def test_list_milestones_no_file_loaded_returns_error(self) -> None:
|
||||
"""list_milestones without loaded file returns NO_FILE_LOADED error."""
|
||||
from xer_mcp.server import set_file_loaded
|
||||
from xer_mcp.tools.list_milestones import list_milestones
|
||||
|
||||
set_file_loaded(False)
|
||||
result = await list_milestones()
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "NO_FILE_LOADED"
|
||||
78
tests/contract/test_list_relationships.py
Normal file
78
tests/contract/test_list_relationships.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Contract tests for list_relationships MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
yield
|
||||
db.clear()
|
||||
|
||||
|
||||
class TestListRelationshipsContract:
|
||||
"""Contract tests verifying list_relationships tool interface."""
|
||||
|
||||
async def test_list_relationships_returns_paginated_results(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""list_relationships returns relationships with pagination metadata."""
|
||||
from xer_mcp.tools.list_relationships import list_relationships
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_relationships()
|
||||
|
||||
assert "relationships" in result
|
||||
assert "pagination" in result
|
||||
assert len(result["relationships"]) == 5
|
||||
assert result["pagination"]["total_count"] == 5
|
||||
|
||||
async def test_list_relationships_with_pagination(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""list_relationships respects limit and offset parameters."""
|
||||
from xer_mcp.tools.list_relationships import list_relationships
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_relationships(limit=2, offset=0)
|
||||
|
||||
assert len(result["relationships"]) == 2
|
||||
assert result["pagination"]["has_more"] is True
|
||||
|
||||
async def test_list_relationships_includes_expected_fields(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""list_relationships returns relationships with all expected fields."""
|
||||
from xer_mcp.tools.list_relationships import list_relationships
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await list_relationships()
|
||||
|
||||
rel = result["relationships"][0]
|
||||
assert "task_pred_id" in rel
|
||||
assert "task_id" in rel
|
||||
assert "pred_task_id" in rel
|
||||
assert "pred_type" in rel
|
||||
assert "lag_hr_cnt" in rel
|
||||
|
||||
async def test_list_relationships_no_file_loaded_returns_error(self) -> None:
|
||||
"""list_relationships without loaded file returns NO_FILE_LOADED error."""
|
||||
from xer_mcp.server import set_file_loaded
|
||||
from xer_mcp.tools.list_relationships import list_relationships
|
||||
|
||||
set_file_loaded(False)
|
||||
result = await list_relationships()
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "NO_FILE_LOADED"
|
||||
107
tests/contract/test_load_xer.py
Normal file
107
tests/contract/test_load_xer.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Contract tests for load_xer MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
yield
|
||||
db.clear()
|
||||
|
||||
|
||||
class TestLoadXerContract:
|
||||
"""Contract tests verifying load_xer tool interface matches spec."""
|
||||
|
||||
async def test_load_single_project_returns_success(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""load_xer with single-project file returns success and project info."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
result = await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
assert result["success"] is True
|
||||
assert "project" in result
|
||||
assert result["project"]["proj_id"] == "1001"
|
||||
assert result["project"]["proj_short_name"] == "Test Project"
|
||||
assert "activity_count" in result
|
||||
assert result["activity_count"] == 5
|
||||
assert "relationship_count" in result
|
||||
assert result["relationship_count"] == 5
|
||||
|
||||
async def test_load_multi_project_without_selection_returns_list(
|
||||
self, sample_xer_multi_project: Path
|
||||
) -> None:
|
||||
"""load_xer with multi-project file without project_id returns available projects."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
result = await load_xer(file_path=str(sample_xer_multi_project))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "available_projects" in result
|
||||
assert len(result["available_projects"]) == 2
|
||||
assert "message" in result
|
||||
assert "project_id" in result["message"].lower()
|
||||
|
||||
async def test_load_multi_project_with_selection_returns_success(
|
||||
self, sample_xer_multi_project: Path
|
||||
) -> None:
|
||||
"""load_xer with multi-project file and project_id returns selected project."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
result = await load_xer(file_path=str(sample_xer_multi_project), project_id="1001")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["project"]["proj_id"] == "1001"
|
||||
assert result["project"]["proj_short_name"] == "Project Alpha"
|
||||
|
||||
async def test_load_nonexistent_file_returns_error(self, nonexistent_xer_path: Path) -> None:
|
||||
"""load_xer with missing file returns FILE_NOT_FOUND error."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
result = await load_xer(file_path=str(nonexistent_xer_path))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "FILE_NOT_FOUND"
|
||||
|
||||
async def test_load_invalid_file_returns_error(self, invalid_xer_file: Path) -> None:
|
||||
"""load_xer with invalid file returns PARSE_ERROR error."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
result = await load_xer(file_path=str(invalid_xer_file))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "PARSE_ERROR"
|
||||
|
||||
async def test_load_replaces_previous_file(
|
||||
self, sample_xer_single_project: Path, sample_xer_empty: Path
|
||||
) -> None:
|
||||
"""Loading a new file replaces the previous file's data."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
# Load first file
|
||||
result1 = await load_xer(file_path=str(sample_xer_single_project))
|
||||
assert result1["activity_count"] == 5
|
||||
|
||||
# Load second file (empty)
|
||||
result2 = await load_xer(file_path=str(sample_xer_empty))
|
||||
assert result2["activity_count"] == 0
|
||||
|
||||
async def test_load_returns_plan_dates(self, sample_xer_single_project: Path) -> None:
|
||||
"""load_xer returns project plan start and end dates."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
result = await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
assert "plan_start_date" in result["project"]
|
||||
assert "plan_end_date" in result["project"]
|
||||
# Dates should be ISO8601 format
|
||||
assert "T" in result["project"]["plan_start_date"]
|
||||
Reference in New Issue
Block a user