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/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""XER MCP Server tests."""
|
||||
129
tests/conftest.py
Normal file
129
tests/conftest.py
Normal 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
|
||||
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"]
|
||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Integration tests for XER MCP Server."""
|
||||
154
tests/integration/test_xer_parsing.py
Normal file
154
tests/integration/test_xer_parsing.py
Normal 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
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests for XER MCP Server."""
|
||||
111
tests/unit/test_db_queries.py
Normal file
111
tests/unit/test_db_queries.py
Normal 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
146
tests/unit/test_parser.py
Normal 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
|
||||
192
tests/unit/test_table_handlers.py
Normal file
192
tests/unit/test_table_handlers.py
Normal 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"
|
||||
Reference in New Issue
Block a user