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:
2026-01-06 21:27:35 -05:00
parent 2cd54118a1
commit ccc8296418
56 changed files with 4140 additions and 0 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""XER MCP Server tests."""

129
tests/conftest.py Normal file
View File

@@ -0,0 +1,129 @@
"""Pytest configuration and fixtures for XER MCP Server tests."""
from pathlib import Path
import pytest
# Sample XER content for testing - minimal but complete
SAMPLE_XER_SINGLE_PROJECT = """\
ERMHDR\t21.12\t2026-01-06\tProject\tADMIN\ttestuser\tdbTest\tProject Management\tUSD
%T\tPROJECT
%F\tproj_id\tfy_start_month_num\trsrc_self_add_flag\tallow_complete_flag\trsrc_multi_assign_flag\tcheckout_flag\tproject_flag\tstep_complete_flag\tcost_qty_recalc_flag\tbatch_sum_flag\tname_sep_char\tdef_complete_pct_type\tproj_short_name\tacct_id\torig_proj_id\tsource_proj_id\tbase_type_id\tclndr_id\tsum_base_proj_id\ttask_code_base\ttask_code_step\tpriority_num\twbs_max_sum_level\tstrgy_priority_num\tlast_checksum\tcritical_drtn_hr_cnt\tdef_cost_per_qty\tlast_recalc_date\tplan_start_date\tplan_end_date\tscd_end_date\tadd_date\tlast_tasksum_date\tfcst_start_date\tdef_duration_type\ttask_code_prefix\tguid\tdef_qty_type\tadd_by_name\tweb_local_root_path\tproj_url\tdef_rate_type\tadd_act_remain_flag\tact_this_per_link_flag\tdef_task_type\tact_pct_link_flag\tcritical_path_type\ttask_code_prefix_flag\tdef_rollup_dates_flag\tuse_project_baseline_flag\trem_target_link_flag\treset_planned_flag\tallow_neg_act_flag\tsum_assign_level\tlast_fin_dates_id\tfintmpl_id\tlast_baseline_update_date\tcr_external_key\tapply_actuals_date\tlocation_id\tloaded_scope_level\texport_flag\tnew_fin_dates_id\tbaselines_to_export\tbaseline_names_to_export\tnext_data_date\tclose_period_flag\tsum_refresh_date\ttrsrcsum_loaded\tsumtask_loaded
%R\t1001\t1\tY\tY\tY\tN\tY\tN\tN\tY\t.\tCP_Drtn\tTest Project\t\t\t\t\t1\t\t1000\t10\t10\t2\t500\t\t0\t0.0000\t2026-01-06 00:00\t2026-01-01 00:00\t2026-06-30 00:00\t2026-06-30 00:00\t2026-01-06 00:00\t\t\tDT_FixedDUR2\tA\ttest-guid-1\tQT_Hour\tADMIN\t\t\tCOST_PER_QTY\tN\tY\tTT_Task\tY\tCT_TotFloat\tY\tY\tY\tY\tN\tN\tSL_Taskrsrc\t\t1\t\t\t\t\t7\tY\t\t\t\t\t\t\t
%T\tCALENDAR
%F\tclndr_id\tdefault_flag\tclndr_name\tproj_id\tbase_clndr_id\tlast_chng_date\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tmonth_hr_cnt\tyear_hr_cnt\trsrc_private\tclndr_data
%R\t1\tY\tStandard 5 Day\t\t\t2026-01-06 00:00\tCA_Base\t8\t40\t160\t2080\tN\t
%T\tPROJWBS
%F\twbs_id\tproj_id\tobs_id\tseq_num\test_wt\tproj_node_flag\tsum_data_flag\tstatus_code\twbs_short_name\twbs_name\tphase_id\tparent_wbs_id\tev_user_pct\tev_etc_user_value\torig_cost\tindep_remain_total_cost\tann_dscnt_rate_pct\tdscnt_period_type\tindep_remain_work_qty\tanticip_start_date\tanticip_end_date\tev_compute_type\tev_etc_compute_type\tguid\ttmpl_guid\tplan_open_state
%R\t100\t1001\t\t1\t1\tY\tN\tWS_Open\tROOT\tTest Project\t\t\t6\t0.88\t0.0000\t0.0000\t\t\t\t\t\tEC_Cmp_pct\tEE_Rem_hr\twbs-guid-1\t\t
%R\t101\t1001\t\t1\t1\tN\tN\tWS_Open\tPH1\tPhase 1\t\t100\t6\t0.88\t0.0000\t0.0000\t\t\t\t\t\tEC_Cmp_pct\tEE_Rem_hr\twbs-guid-2\t\t
%R\t102\t1001\t\t2\t1\tN\tN\tWS_Open\tPH2\tPhase 2\t\t100\t6\t0.88\t0.0000\t0.0000\t\t\t\t\t\tEC_Cmp_pct\tEE_Rem_hr\twbs-guid-3\t\t
%T\tTASK
%F\ttask_id\tproj_id\twbs_id\tclndr_id\tphys_complete_pct\trev_fdbk_flag\test_wt\tlock_plan_flag\tauto_compute_act_flag\tcomplete_pct_type\ttask_type\tduration_type\tstatus_code\ttask_code\ttask_name\trsrc_id\ttotal_float_hr_cnt\tfree_float_hr_cnt\tremain_drtn_hr_cnt\tact_work_qty\tremain_work_qty\ttarget_work_qty\ttarget_drtn_hr_cnt\ttarget_equip_qty\tact_equip_qty\tremain_equip_qty\tcstr_date\tact_start_date\tact_end_date\tlate_start_date\tlate_end_date\texpect_end_date\tearly_start_date\tearly_end_date\trestart_date\treend_date\ttarget_start_date\ttarget_end_date\trem_late_start_date\trem_late_end_date\tcstr_type\tpriority_type\tsuspend_date\tresume_date\tfloat_path\tfloat_path_order\tguid\ttmpl_guid\tcstr_date2\tcstr_type2\tdriving_path_flag\tact_this_per_work_qty\tact_this_per_equip_qty\texternal_early_start_date\texternal_late_end_date\tcreate_date\tupdate_date\tcreate_user\tupdate_user\tlocation_id\tcrt_path_num
%R\t2001\t1001\t101\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Mile\tDT_FixedDrtn\tTK_NotStart\tA1000\tProject Start\t\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t\t\t\t2026-01-01 07:00\t2026-01-01 07:00\t\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t\tPT_Normal\t\t\t1\t1\ttask-guid-1\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t
%R\t2002\t1001\t101\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tA1010\tSite Preparation\t\t0\t0\t40\t0\t0\t0\t40\t0\t0\t0\t\t\t\t2026-01-02 07:00\t2026-01-08 15:00\t\t2026-01-02 07:00\t2026-01-08 15:00\t2026-01-02 07:00\t2026-01-08 15:00\t2026-01-02 07:00\t2026-01-08 15:00\t2026-01-02 07:00\t2026-01-08 15:00\t\tPT_Normal\t\t\t1\t2\ttask-guid-2\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t
%R\t2003\t1001\t101\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tA1020\tFoundation Work\t\t80\t0\t80\t0\t0\t0\t80\t0\t0\t0\t\t\t\t2026-01-09 07:00\t2026-01-22 15:00\t\t2026-01-09 07:00\t2026-01-22 15:00\t2026-01-09 07:00\t2026-01-22 15:00\t2026-01-09 07:00\t2026-01-22 15:00\t2026-01-09 07:00\t2026-01-22 15:00\t\tPT_Normal\t\t\t1\t3\ttask-guid-3\t\t\t\tN\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t
%R\t2004\t1001\t102\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tA1030\tStructural Work\t\t0\t0\t160\t0\t0\t0\t160\t0\t0\t0\t\t\t\t2026-01-23 07:00\t2026-02-19 15:00\t\t2026-01-23 07:00\t2026-02-19 15:00\t2026-01-23 07:00\t2026-02-19 15:00\t2026-01-23 07:00\t2026-02-19 15:00\t2026-01-23 07:00\t2026-02-19 15:00\t\tPT_Normal\t\t\t1\t4\ttask-guid-4\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t
%R\t2005\t1001\t102\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Mile\tDT_FixedDrtn\tTK_NotStart\tA1040\tProject Complete\t\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t\t\t\t2026-02-20 07:00\t2026-02-20 07:00\t\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t\tPT_Normal\t\t\t1\t5\ttask-guid-5\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t
%T\tTASKPRED
%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt\tcomments\tfloat_path\taref\tarls
%R\t3001\t2002\t2001\t1001\t1001\tPR_FS\t0\t\t\t2026-01-01 07:00\t2026-01-02 07:00
%R\t3002\t2003\t2002\t1001\t1001\tPR_FS\t0\t\t\t2026-01-08 15:00\t2026-01-09 07:00
%R\t3003\t2004\t2003\t1001\t1001\tPR_FS\t0\t\t\t2026-01-22 15:00\t2026-01-23 07:00
%R\t3004\t2005\t2004\t1001\t1001\tPR_FS\t0\t\t\t2026-02-19 15:00\t2026-02-20 07:00
%R\t3005\t2004\t2002\t1001\t1001\tPR_SS\t40\t\t\t2026-01-02 07:00\t2026-01-08 07:00
%T\tEND
"""
SAMPLE_XER_MULTI_PROJECT = """\
ERMHDR\t21.12\t2026-01-06\tProject\tADMIN\ttestuser\tdbTest\tProject Management\tUSD
%T\tPROJECT
%F\tproj_id\tfy_start_month_num\trsrc_self_add_flag\tallow_complete_flag\trsrc_multi_assign_flag\tcheckout_flag\tproject_flag\tstep_complete_flag\tcost_qty_recalc_flag\tbatch_sum_flag\tname_sep_char\tdef_complete_pct_type\tproj_short_name\tacct_id\torig_proj_id\tsource_proj_id\tbase_type_id\tclndr_id\tsum_base_proj_id\ttask_code_base\ttask_code_step\tpriority_num\twbs_max_sum_level\tstrgy_priority_num\tlast_checksum\tcritical_drtn_hr_cnt\tdef_cost_per_qty\tlast_recalc_date\tplan_start_date\tplan_end_date\tscd_end_date\tadd_date\tlast_tasksum_date\tfcst_start_date\tdef_duration_type\ttask_code_prefix\tguid\tdef_qty_type\tadd_by_name\tweb_local_root_path\tproj_url\tdef_rate_type\tadd_act_remain_flag\tact_this_per_link_flag\tdef_task_type\tact_pct_link_flag\tcritical_path_type\ttask_code_prefix_flag\tdef_rollup_dates_flag\tuse_project_baseline_flag\trem_target_link_flag\treset_planned_flag\tallow_neg_act_flag\tsum_assign_level\tlast_fin_dates_id\tfintmpl_id\tlast_baseline_update_date\tcr_external_key\tapply_actuals_date\tlocation_id\tloaded_scope_level\texport_flag\tnew_fin_dates_id\tbaselines_to_export\tbaseline_names_to_export\tnext_data_date\tclose_period_flag\tsum_refresh_date\ttrsrcsum_loaded\tsumtask_loaded
%R\t1001\t1\tY\tY\tY\tN\tY\tN\tN\tY\t.\tCP_Drtn\tProject Alpha\t\t\t\t\t1\t\t1000\t10\t10\t2\t500\t\t0\t0.0000\t2026-01-06 00:00\t2026-01-01 00:00\t2026-03-31 00:00\t2026-03-31 00:00\t2026-01-06 00:00\t\t\tDT_FixedDUR2\tA\ttest-guid-1\tQT_Hour\tADMIN\t\t\tCOST_PER_QTY\tN\tY\tTT_Task\tY\tCT_TotFloat\tY\tY\tY\tY\tN\tN\tSL_Taskrsrc\t\t1\t\t\t\t\t7\tY\t\t\t\t\t\t\t
%R\t1002\t1\tY\tY\tY\tN\tY\tN\tN\tY\t.\tCP_Drtn\tProject Beta\t\t\t\t\t1\t\t1000\t10\t10\t2\t500\t\t0\t0.0000\t2026-01-06 00:00\t2026-04-01 00:00\t2026-06-30 00:00\t2026-06-30 00:00\t2026-01-06 00:00\t\t\tDT_FixedDUR2\tB\ttest-guid-2\tQT_Hour\tADMIN\t\t\tCOST_PER_QTY\tN\tY\tTT_Task\tY\tCT_TotFloat\tY\tY\tY\tY\tN\tN\tSL_Taskrsrc\t\t1\t\t\t\t\t7\tY\t\t\t\t\t\t\t
%T\tCALENDAR
%F\tclndr_id\tdefault_flag\tclndr_name\tproj_id\tbase_clndr_id\tlast_chng_date\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tmonth_hr_cnt\tyear_hr_cnt\trsrc_private\tclndr_data
%R\t1\tY\tStandard 5 Day\t\t\t2026-01-06 00:00\tCA_Base\t8\t40\t160\t2080\tN\t
%T\tPROJWBS
%F\twbs_id\tproj_id\tobs_id\tseq_num\test_wt\tproj_node_flag\tsum_data_flag\tstatus_code\twbs_short_name\twbs_name\tphase_id\tparent_wbs_id\tev_user_pct\tev_etc_user_value\torig_cost\tindep_remain_total_cost\tann_dscnt_rate_pct\tdscnt_period_type\tindep_remain_work_qty\tanticip_start_date\tanticip_end_date\tev_compute_type\tev_etc_compute_type\tguid\ttmpl_guid\tplan_open_state
%R\t100\t1001\t\t1\t1\tY\tN\tWS_Open\tALPHA\tProject Alpha\t\t\t6\t0.88\t0.0000\t0.0000\t\t\t\t\t\tEC_Cmp_pct\tEE_Rem_hr\twbs-guid-1\t\t
%R\t200\t1002\t\t1\t1\tY\tN\tWS_Open\tBETA\tProject Beta\t\t\t6\t0.88\t0.0000\t0.0000\t\t\t\t\t\tEC_Cmp_pct\tEE_Rem_hr\twbs-guid-2\t\t
%T\tTASK
%F\ttask_id\tproj_id\twbs_id\tclndr_id\tphys_complete_pct\trev_fdbk_flag\test_wt\tlock_plan_flag\tauto_compute_act_flag\tcomplete_pct_type\ttask_type\tduration_type\tstatus_code\ttask_code\ttask_name\trsrc_id\ttotal_float_hr_cnt\tfree_float_hr_cnt\tremain_drtn_hr_cnt\tact_work_qty\tremain_work_qty\ttarget_work_qty\ttarget_drtn_hr_cnt\ttarget_equip_qty\tact_equip_qty\tremain_equip_qty\tcstr_date\tact_start_date\tact_end_date\tlate_start_date\tlate_end_date\texpect_end_date\tearly_start_date\tearly_end_date\trestart_date\treend_date\ttarget_start_date\ttarget_end_date\trem_late_start_date\trem_late_end_date\tcstr_type\tpriority_type\tsuspend_date\tresume_date\tfloat_path\tfloat_path_order\tguid\ttmpl_guid\tcstr_date2\tcstr_type2\tdriving_path_flag\tact_this_per_work_qty\tact_this_per_equip_qty\texternal_early_start_date\texternal_late_end_date\tcreate_date\tupdate_date\tcreate_user\tupdate_user\tlocation_id\tcrt_path_num
%R\t2001\t1001\t100\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tA1000\tAlpha Task 1\t\t0\t0\t40\t0\t0\t0\t40\t0\t0\t0\t\t\t\t2026-01-01 07:00\t2026-01-08 15:00\t\t2026-01-01 07:00\t2026-01-08 15:00\t2026-01-01 07:00\t2026-01-08 15:00\t2026-01-01 07:00\t2026-01-08 15:00\t2026-01-01 07:00\t2026-01-08 15:00\t\tPT_Normal\t\t\t\t\ttask-guid-1\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t
%R\t2002\t1002\t200\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tB1000\tBeta Task 1\t\t0\t0\t40\t0\t0\t0\t40\t0\t0\t0\t\t\t\t2026-04-01 07:00\t2026-04-08 15:00\t\t2026-04-01 07:00\t2026-04-08 15:00\t2026-04-01 07:00\t2026-04-08 15:00\t2026-04-01 07:00\t2026-04-08 15:00\t2026-04-01 07:00\t2026-04-08 15:00\t\tPT_Normal\t\t\t\t\ttask-guid-2\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t
%T\tTASKPRED
%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt\tcomments\tfloat_path\taref\tarls
%T\tEND
"""
SAMPLE_XER_EMPTY = """\
ERMHDR\t21.12\t2026-01-06\tProject\tADMIN\ttestuser\tdbTest\tProject Management\tUSD
%T\tPROJECT
%F\tproj_id\tfy_start_month_num\trsrc_self_add_flag\tallow_complete_flag\trsrc_multi_assign_flag\tcheckout_flag\tproject_flag\tstep_complete_flag\tcost_qty_recalc_flag\tbatch_sum_flag\tname_sep_char\tdef_complete_pct_type\tproj_short_name\tacct_id\torig_proj_id\tsource_proj_id\tbase_type_id\tclndr_id\tsum_base_proj_id\ttask_code_base\ttask_code_step\tpriority_num\twbs_max_sum_level\tstrgy_priority_num\tlast_checksum\tcritical_drtn_hr_cnt\tdef_cost_per_qty\tlast_recalc_date\tplan_start_date\tplan_end_date\tscd_end_date\tadd_date\tlast_tasksum_date\tfcst_start_date\tdef_duration_type\ttask_code_prefix\tguid\tdef_qty_type\tadd_by_name\tweb_local_root_path\tproj_url\tdef_rate_type\tadd_act_remain_flag\tact_this_per_link_flag\tdef_task_type\tact_pct_link_flag\tcritical_path_type\ttask_code_prefix_flag\tdef_rollup_dates_flag\tuse_project_baseline_flag\trem_target_link_flag\treset_planned_flag\tallow_neg_act_flag\tsum_assign_level\tlast_fin_dates_id\tfintmpl_id\tlast_baseline_update_date\tcr_external_key\tapply_actuals_date\tlocation_id\tloaded_scope_level\texport_flag\tnew_fin_dates_id\tbaselines_to_export\tbaseline_names_to_export\tnext_data_date\tclose_period_flag\tsum_refresh_date\ttrsrcsum_loaded\tsumtask_loaded
%R\t1001\t1\tY\tY\tY\tN\tY\tN\tN\tY\t.\tCP_Drtn\tEmpty Project\t\t\t\t\t1\t\t1000\t10\t10\t2\t500\t\t0\t0.0000\t2026-01-06 00:00\t2026-01-01 00:00\t2026-06-30 00:00\t2026-06-30 00:00\t2026-01-06 00:00\t\t\tDT_FixedDUR2\tA\ttest-guid-1\tQT_Hour\tADMIN\t\t\tCOST_PER_QTY\tN\tY\tTT_Task\tY\tCT_TotFloat\tY\tY\tY\tY\tN\tN\tSL_Taskrsrc\t\t1\t\t\t\t\t7\tY\t\t\t\t\t\t\t
%T\tCALENDAR
%F\tclndr_id\tdefault_flag\tclndr_name\tproj_id\tbase_clndr_id\tlast_chng_date\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tmonth_hr_cnt\tyear_hr_cnt\trsrc_private\tclndr_data
%R\t1\tY\tStandard 5 Day\t\t\t2026-01-06 00:00\tCA_Base\t8\t40\t160\t2080\tN\t
%T\tPROJWBS
%F\twbs_id\tproj_id\tobs_id\tseq_num\test_wt\tproj_node_flag\tsum_data_flag\tstatus_code\twbs_short_name\twbs_name\tphase_id\tparent_wbs_id\tev_user_pct\tev_etc_user_value\torig_cost\tindep_remain_total_cost\tann_dscnt_rate_pct\tdscnt_period_type\tindep_remain_work_qty\tanticip_start_date\tanticip_end_date\tev_compute_type\tev_etc_compute_type\tguid\ttmpl_guid\tplan_open_state
%R\t100\t1001\t\t1\t1\tY\tN\tWS_Open\tROOT\tEmpty Project\t\t\t6\t0.88\t0.0000\t0.0000\t\t\t\t\t\tEC_Cmp_pct\tEE_Rem_hr\twbs-guid-1\t\t
%T\tTASK
%F\ttask_id\tproj_id\twbs_id\tclndr_id\tphys_complete_pct\trev_fdbk_flag\test_wt\tlock_plan_flag\tauto_compute_act_flag\tcomplete_pct_type\ttask_type\tduration_type\tstatus_code\ttask_code\ttask_name\trsrc_id\ttotal_float_hr_cnt\tfree_float_hr_cnt\tremain_drtn_hr_cnt\tact_work_qty\tremain_work_qty\ttarget_work_qty\ttarget_drtn_hr_cnt\ttarget_equip_qty\tact_equip_qty\tremain_equip_qty\tcstr_date\tact_start_date\tact_end_date\tlate_start_date\tlate_end_date\texpect_end_date\tearly_start_date\tearly_end_date\trestart_date\treend_date\ttarget_start_date\ttarget_end_date\trem_late_start_date\trem_late_end_date\tcstr_type\tpriority_type\tsuspend_date\tresume_date\tfloat_path\tfloat_path_order\tguid\ttmpl_guid\tcstr_date2\tcstr_type2\tdriving_path_flag\tact_this_per_work_qty\tact_this_per_equip_qty\texternal_early_start_date\texternal_late_end_date\tcreate_date\tupdate_date\tcreate_user\tupdate_user\tlocation_id\tcrt_path_num
%T\tTASKPRED
%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt\tcomments\tfloat_path\taref\tarls
%T\tEND
"""
@pytest.fixture
def sample_xer_single_project(tmp_path: Path) -> Path:
"""Create a temporary XER file with a single project."""
xer_file = tmp_path / "single_project.xer"
xer_file.write_text(SAMPLE_XER_SINGLE_PROJECT)
return xer_file
@pytest.fixture
def sample_xer_multi_project(tmp_path: Path) -> Path:
"""Create a temporary XER file with multiple projects."""
xer_file = tmp_path / "multi_project.xer"
xer_file.write_text(SAMPLE_XER_MULTI_PROJECT)
return xer_file
@pytest.fixture
def sample_xer_empty(tmp_path: Path) -> Path:
"""Create a temporary XER file with no activities."""
xer_file = tmp_path / "empty_project.xer"
xer_file.write_text(SAMPLE_XER_EMPTY)
return xer_file
@pytest.fixture
def nonexistent_xer_path(tmp_path: Path) -> Path:
"""Return a path to a non-existent XER file."""
return tmp_path / "does_not_exist.xer"
@pytest.fixture
def invalid_xer_file(tmp_path: Path) -> Path:
"""Create a temporary file with invalid XER content."""
xer_file = tmp_path / "invalid.xer"
xer_file.write_text("This is not a valid XER file\nJust some random text")
return xer_file
@pytest.fixture
def real_xer_file() -> Path | None:
"""Return the path to the real XER file if it exists.
This fixture provides access to the actual XER file in the repository
for integration testing with real data.
"""
real_file = Path(
"/home/bill/xer-mcp/S48019R - Proposal Schedule - E-J Electric Installation.xer"
)
if real_file.exists():
return real_file
return None

View File

@@ -0,0 +1 @@
"""Contract tests for MCP tool interfaces."""

View 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"

View 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"

View 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"

View 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"

View 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"

View 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

View 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"

View 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"

View 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"]

View File

@@ -0,0 +1 @@
"""Integration tests for XER MCP Server."""

View File

@@ -0,0 +1,154 @@
"""Integration tests for XER parsing and database loading."""
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 TestXerParsing:
"""Integration tests for parsing XER files and loading into database."""
def test_load_single_project_xer(self, sample_xer_single_project: Path) -> None:
"""Should parse XER and load data into SQLite database."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
# Load all data for the single project
load_parsed_data(parsed, project_id="1001")
# Verify data in database
with db.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM projects")
assert cur.fetchone()[0] == 1
cur.execute("SELECT COUNT(*) FROM activities")
assert cur.fetchone()[0] == 5
cur.execute("SELECT COUNT(*) FROM relationships")
assert cur.fetchone()[0] == 5
cur.execute("SELECT COUNT(*) FROM wbs")
assert cur.fetchone()[0] == 3
def test_load_preserves_date_precision(self, sample_xer_single_project: Path) -> None:
"""Should preserve date/time precision from XER file."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
load_parsed_data(parsed, project_id="1001")
with db.cursor() as cur:
cur.execute("SELECT target_start_date FROM activities WHERE task_code = ?", ("A1010",))
date_str = cur.fetchone()[0]
# Should be ISO8601 with time component
assert "T" in date_str
assert "07:00" in date_str
def test_load_activities_indexed_by_type(self, sample_xer_single_project: Path) -> None:
"""Should be able to efficiently query activities by type."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
load_parsed_data(parsed, project_id="1001")
with db.cursor() as cur:
# Query milestones
cur.execute("SELECT COUNT(*) FROM activities WHERE task_type = ?", ("TT_Mile",))
milestone_count = cur.fetchone()[0]
assert milestone_count == 2
# Query tasks
cur.execute("SELECT COUNT(*) FROM activities WHERE task_type = ?", ("TT_Task",))
task_count = cur.fetchone()[0]
assert task_count == 3
def test_load_critical_path_activities(self, sample_xer_single_project: Path) -> None:
"""Should be able to query critical path activities via index."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
load_parsed_data(parsed, project_id="1001")
with db.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM activities WHERE driving_path_flag = 1")
critical_count = cur.fetchone()[0]
# Activities A1000, A1010, A1030, A1040 are on critical path
assert critical_count == 4
def test_load_replaces_previous_data(self, sample_xer_single_project: Path) -> None:
"""Loading a new file should replace previous data."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
# Load first time
load_parsed_data(parsed, project_id="1001")
with db.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM activities")
first_count = cur.fetchone()[0]
# Clear and load again
db.clear()
load_parsed_data(parsed, project_id="1001")
with db.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM activities")
second_count = cur.fetchone()[0]
assert first_count == second_count
class TestMultiProjectHandling:
"""Integration tests for multi-project XER file handling."""
def test_load_selected_project_from_multi(self, sample_xer_multi_project: Path) -> None:
"""Should load only the selected project from multi-project file."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_multi_project)
# Load only Project Alpha
load_parsed_data(parsed, project_id="1001")
with db.cursor() as cur:
cur.execute("SELECT proj_short_name FROM projects")
names = [row[0] for row in cur.fetchall()]
assert names == ["Project Alpha"]
cur.execute("SELECT COUNT(*) FROM activities WHERE proj_id = ?", ("1001",))
assert cur.fetchone()[0] == 1
def test_multi_project_list_available(self, sample_xer_multi_project: Path) -> None:
"""Parser should report all available projects."""
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_multi_project)
assert len(parsed.projects) == 2
proj_ids = {p["proj_id"] for p in parsed.projects}
assert proj_ids == {"1001", "1002"}

1
tests/unit/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Unit tests for XER MCP Server."""

View File

@@ -0,0 +1,111 @@
"""Unit tests for database query functions."""
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 TestActivityQueries:
"""Tests for activity query functions."""
def test_query_activities_with_pagination(self, sample_xer_single_project: Path) -> None:
"""Should return paginated activity results."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.db.queries import query_activities
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
load_parsed_data(parsed, project_id="1001")
activities, total = query_activities(limit=2, offset=0)
assert len(activities) == 2
assert total == 5
def test_query_activities_filter_by_type(self, sample_xer_single_project: Path) -> None:
"""Should filter activities by task type."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.db.queries import query_activities
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
load_parsed_data(parsed, project_id="1001")
activities, total = query_activities(activity_type="TT_Mile")
assert total == 2
for act in activities:
assert act["task_type"] == "TT_Mile"
def test_query_activities_filter_by_date_range(self, sample_xer_single_project: Path) -> None:
"""Should filter activities by date range."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.db.queries import query_activities
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
load_parsed_data(parsed, project_id="1001")
# Filter to very narrow range
activities, total = query_activities(start_date="2026-01-01", end_date="2026-01-01")
# Only activities starting on 2026-01-01
for act in activities:
assert "2026-01-01" in act["target_start_date"]
def test_query_activities_filter_by_wbs(self, sample_xer_single_project: Path) -> None:
"""Should filter activities by WBS ID."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.db.queries import query_activities
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
load_parsed_data(parsed, project_id="1001")
activities, total = query_activities(wbs_id="102")
# WBS 102 has 2 activities
assert total == 2
def test_get_activity_by_id(self, sample_xer_single_project: Path) -> None:
"""Should return single activity by ID."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.db.queries import get_activity_by_id
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
load_parsed_data(parsed, project_id="1001")
activity = get_activity_by_id("2002")
assert activity is not None
assert activity["task_code"] == "A1010"
def test_get_activity_by_id_not_found(self, sample_xer_single_project: Path) -> None:
"""Should return None for non-existent activity."""
from xer_mcp.db.loader import load_parsed_data
from xer_mcp.db.queries import get_activity_by_id
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
parsed = parser.parse(sample_xer_single_project)
load_parsed_data(parsed, project_id="1001")
activity = get_activity_by_id("nonexistent")
assert activity is None

146
tests/unit/test_parser.py Normal file
View File

@@ -0,0 +1,146 @@
"""Unit tests for XER parser."""
from pathlib import Path
import pytest
class TestXerParser:
"""Tests for the XER file parser."""
def test_parse_single_project_file(self, sample_xer_single_project: Path) -> None:
"""Parser should extract project data from single-project XER file."""
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
result = parser.parse(sample_xer_single_project)
assert len(result.projects) == 1
assert result.projects[0]["proj_id"] == "1001"
assert result.projects[0]["proj_short_name"] == "Test Project"
def test_parse_multi_project_file(self, sample_xer_multi_project: Path) -> None:
"""Parser should extract all projects from multi-project XER file."""
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
result = parser.parse(sample_xer_multi_project)
assert len(result.projects) == 2
project_names = {p["proj_short_name"] for p in result.projects}
assert project_names == {"Project Alpha", "Project Beta"}
def test_parse_activities(self, sample_xer_single_project: Path) -> None:
"""Parser should extract activities from XER file."""
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
result = parser.parse(sample_xer_single_project)
# Single project fixture has 5 activities
assert len(result.tasks) == 5
# Check first milestone
milestone = next(t for t in result.tasks if t["task_code"] == "A1000")
assert milestone["task_name"] == "Project Start"
assert milestone["task_type"] == "TT_Mile"
def test_parse_relationships(self, sample_xer_single_project: Path) -> None:
"""Parser should extract relationships from XER file."""
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
result = parser.parse(sample_xer_single_project)
# Single project fixture has 5 relationships
assert len(result.taskpreds) == 5
# Check a FS relationship
fs_rel = next(r for r in result.taskpreds if r["pred_type"] == "PR_FS")
assert fs_rel["lag_hr_cnt"] == 0
# Check a SS relationship
ss_rel = next(r for r in result.taskpreds if r["pred_type"] == "PR_SS")
assert ss_rel["lag_hr_cnt"] == 40
def test_parse_wbs(self, sample_xer_single_project: Path) -> None:
"""Parser should extract WBS hierarchy from XER file."""
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
result = parser.parse(sample_xer_single_project)
# Single project fixture has 3 WBS elements
assert len(result.projwbs) == 3
# Check root WBS
root = next(w for w in result.projwbs if w["wbs_short_name"] == "ROOT")
assert root["parent_wbs_id"] is None or root["parent_wbs_id"] == ""
def test_parse_calendars(self, sample_xer_single_project: Path) -> None:
"""Parser should extract calendars from XER file."""
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
result = parser.parse(sample_xer_single_project)
# Single project fixture has 1 calendar
assert len(result.calendars) == 1
cal = result.calendars[0]
assert cal["clndr_name"] == "Standard 5 Day"
assert cal["day_hr_cnt"] == 8
def test_parse_empty_project(self, sample_xer_empty: Path) -> None:
"""Parser should handle XER file with no activities."""
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
result = parser.parse(sample_xer_empty)
assert len(result.projects) == 1
assert len(result.tasks) == 0
assert len(result.taskpreds) == 0
def test_parse_invalid_file_raises_error(self, invalid_xer_file: Path) -> None:
"""Parser should raise ParseError for invalid XER content."""
from xer_mcp.errors import ParseError
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
with pytest.raises(ParseError):
parser.parse(invalid_xer_file)
def test_parse_nonexistent_file_raises_error(self, nonexistent_xer_path: Path) -> None:
"""Parser should raise FileNotFoundError for missing file."""
from xer_mcp.errors import FileNotFoundError
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
with pytest.raises(FileNotFoundError):
parser.parse(nonexistent_xer_path)
def test_parse_dates_converted_to_iso8601(self, sample_xer_single_project: Path) -> None:
"""Parser should convert XER dates to ISO8601 format."""
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
result = parser.parse(sample_xer_single_project)
# Check date conversion (XER: "2026-01-01 07:00" -> ISO: "2026-01-01T07:00:00")
task = next(t for t in result.tasks if t["task_code"] == "A1000")
assert "T" in task["target_start_date"]
def test_parse_driving_path_flag(self, sample_xer_single_project: Path) -> None:
"""Parser should correctly parse driving_path_flag as boolean."""
from xer_mcp.parser.xer_parser import XerParser
parser = XerParser()
result = parser.parse(sample_xer_single_project)
# A1000 has driving_path_flag = Y
critical_task = next(t for t in result.tasks if t["task_code"] == "A1000")
assert critical_task["driving_path_flag"] is True
# A1020 has driving_path_flag = N
non_critical = next(t for t in result.tasks if t["task_code"] == "A1020")
assert non_critical["driving_path_flag"] is False

View File

@@ -0,0 +1,192 @@
"""Unit tests for XER table handlers."""
class TestProjectHandler:
"""Tests for PROJECT table handler."""
def test_parse_project_row(self) -> None:
"""Handler should parse PROJECT row correctly."""
from xer_mcp.parser.table_handlers.project import ProjectHandler
handler = ProjectHandler()
# Minimal PROJECT fields
fields = [
"proj_id",
"proj_short_name",
"plan_start_date",
"plan_end_date",
]
values = ["1001", "Test Project", "2026-01-01 00:00", "2026-06-30 00:00"]
result = handler.parse_row(fields, values)
assert result is not None
assert result["proj_id"] == "1001"
assert result["proj_short_name"] == "Test Project"
assert result["plan_start_date"] == "2026-01-01T00:00:00"
assert result["plan_end_date"] == "2026-06-30T00:00:00"
def test_table_name(self) -> None:
"""Handler should report correct table name."""
from xer_mcp.parser.table_handlers.project import ProjectHandler
handler = ProjectHandler()
assert handler.table_name == "PROJECT"
class TestTaskHandler:
"""Tests for TASK table handler."""
def test_parse_task_row(self) -> None:
"""Handler should parse TASK row correctly."""
from xer_mcp.parser.table_handlers.task import TaskHandler
handler = TaskHandler()
fields = [
"task_id",
"proj_id",
"wbs_id",
"task_code",
"task_name",
"task_type",
"status_code",
"target_start_date",
"target_end_date",
"total_float_hr_cnt",
"driving_path_flag",
]
values = [
"2001",
"1001",
"100",
"A1000",
"Site Prep",
"TT_Task",
"TK_NotStart",
"2026-01-02 07:00",
"2026-01-08 15:00",
"0",
"Y",
]
result = handler.parse_row(fields, values)
assert result is not None
assert result["task_id"] == "2001"
assert result["task_code"] == "A1000"
assert result["task_type"] == "TT_Task"
assert result["driving_path_flag"] is True
assert result["total_float_hr_cnt"] == 0.0
def test_table_name(self) -> None:
"""Handler should report correct table name."""
from xer_mcp.parser.table_handlers.task import TaskHandler
handler = TaskHandler()
assert handler.table_name == "TASK"
class TestTaskpredHandler:
"""Tests for TASKPRED table handler."""
def test_parse_relationship_row(self) -> None:
"""Handler should parse TASKPRED row correctly."""
from xer_mcp.parser.table_handlers.taskpred import TaskpredHandler
handler = TaskpredHandler()
fields = [
"task_pred_id",
"task_id",
"pred_task_id",
"proj_id",
"pred_proj_id",
"pred_type",
"lag_hr_cnt",
]
values = ["3001", "2002", "2001", "1001", "1001", "PR_FS", "8"]
result = handler.parse_row(fields, values)
assert result is not None
assert result["task_pred_id"] == "3001"
assert result["task_id"] == "2002"
assert result["pred_task_id"] == "2001"
assert result["pred_type"] == "PR_FS"
assert result["lag_hr_cnt"] == 8.0
def test_table_name(self) -> None:
"""Handler should report correct table name."""
from xer_mcp.parser.table_handlers.taskpred import TaskpredHandler
handler = TaskpredHandler()
assert handler.table_name == "TASKPRED"
class TestProjwbsHandler:
"""Tests for PROJWBS table handler."""
def test_parse_wbs_row(self) -> None:
"""Handler should parse PROJWBS row correctly."""
from xer_mcp.parser.table_handlers.projwbs import ProjwbsHandler
handler = ProjwbsHandler()
fields = [
"wbs_id",
"proj_id",
"parent_wbs_id",
"wbs_short_name",
"wbs_name",
]
values = ["100", "1001", "", "ROOT", "Project Root"]
result = handler.parse_row(fields, values)
assert result is not None
assert result["wbs_id"] == "100"
assert result["proj_id"] == "1001"
assert result["parent_wbs_id"] == ""
assert result["wbs_short_name"] == "ROOT"
def test_table_name(self) -> None:
"""Handler should report correct table name."""
from xer_mcp.parser.table_handlers.projwbs import ProjwbsHandler
handler = ProjwbsHandler()
assert handler.table_name == "PROJWBS"
class TestCalendarHandler:
"""Tests for CALENDAR table handler."""
def test_parse_calendar_row(self) -> None:
"""Handler should parse CALENDAR row correctly."""
from xer_mcp.parser.table_handlers.calendar import CalendarHandler
handler = CalendarHandler()
fields = [
"clndr_id",
"clndr_name",
"day_hr_cnt",
"week_hr_cnt",
]
values = ["1", "Standard 5 Day", "8", "40"]
result = handler.parse_row(fields, values)
assert result is not None
assert result["clndr_id"] == "1"
assert result["clndr_name"] == "Standard 5 Day"
assert result["day_hr_cnt"] == 8.0
assert result["week_hr_cnt"] == 40.0
def test_table_name(self) -> None:
"""Handler should report correct table name."""
from xer_mcp.parser.table_handlers.calendar import CalendarHandler
handler = CalendarHandler()
assert handler.table_name == "CALENDAR"