aero-agent-roles 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/NOTICE +18 -0
  2. package/README.md +49 -0
  3. package/bin/aero-agent-roles.js +127 -0
  4. package/lib/catalog.js +97 -0
  5. package/lib/install.js +48 -0
  6. package/lib/mcp.js +199 -0
  7. package/manifest.json +516 -0
  8. package/package.json +44 -0
  9. package/roles/aerodynamics-engineer/ROLE.md +153 -0
  10. package/roles/aerodynamics-engineer/SOURCES.md +12 -0
  11. package/roles/aerodynamics-engineer/cli.py +143 -0
  12. package/roles/aerodynamics-engineer/core/aerodynamics_core.py +1218 -0
  13. package/roles/aerodynamics-engineer/templates/aero-design-report-template.md +72 -0
  14. package/roles/aerodynamics-engineer/tests/test_aerodynamics_core.py +227 -0
  15. package/roles/aerodynamics-engineer/tests/test_role_aerodynamics.py +94 -0
  16. package/roles/aircraft-design-engineer/ROLE.md +138 -0
  17. package/roles/aircraft-design-engineer/SOURCES.md +12 -0
  18. package/roles/aircraft-design-engineer/cli.py +152 -0
  19. package/roles/aircraft-design-engineer/core/aircraft_design_core.py +1278 -0
  20. package/roles/aircraft-design-engineer/templates/concept-design-template.md +99 -0
  21. package/roles/aircraft-design-engineer/tests/test_aircraft_design_core.py +269 -0
  22. package/roles/aircraft-design-engineer/tests/test_role_aircraft_design_engineer.py +106 -0
  23. package/roles/airworthiness-compliance-engineer/ROLE.md +122 -0
  24. package/roles/airworthiness-compliance-engineer/SOURCES.md +16 -0
  25. package/roles/airworthiness-compliance-engineer/cli.py +146 -0
  26. package/roles/airworthiness-compliance-engineer/core/airworthiness_core.py +894 -0
  27. package/roles/airworthiness-compliance-engineer/templates/compliance-matrix-template.md +46 -0
  28. package/roles/airworthiness-compliance-engineer/tests/test_airworthiness_core.py +224 -0
  29. package/roles/airworthiness-compliance-engineer/tests/test_role_airworthiness.py +85 -0
  30. package/roles/as9100-quality-auditor/ROLE.md +128 -0
  31. package/roles/as9100-quality-auditor/SOURCES.md +16 -0
  32. package/roles/as9100-quality-auditor/cli.py +136 -0
  33. package/roles/as9100-quality-auditor/core/as9100_core.py +857 -0
  34. package/roles/as9100-quality-auditor/templates/findings-report-template.md +119 -0
  35. package/roles/as9100-quality-auditor/tests/test_as9100_cli_profile.py +105 -0
  36. package/roles/as9100-quality-auditor/tests/test_as9100_core.py +204 -0
  37. package/roles/as9100-quality-auditor/tests/test_role_as9100.py +90 -0
  38. package/roles/data-bus-avionics-engineer/ROLE.md +146 -0
  39. package/roles/data-bus-avionics-engineer/SOURCES.md +23 -0
  40. package/roles/data-bus-avionics-engineer/cli.py +311 -0
  41. package/roles/data-bus-avionics-engineer/core/data_bus_avionics_core.py +1121 -0
  42. package/roles/data-bus-avionics-engineer/templates/bus-assessment-template.md +134 -0
  43. package/roles/data-bus-avionics-engineer/tests/test_bundle_protocol.py +137 -0
  44. package/roles/data-bus-avionics-engineer/tests/test_data_bus_avionics_core.py +261 -0
  45. package/roles/data-bus-avionics-engineer/tests/test_role_data_bus_avionics_engineer.py +121 -0
  46. package/roles/do160-environmental-engineer/ROLE.md +136 -0
  47. package/roles/do160-environmental-engineer/SOURCES.md +30 -0
  48. package/roles/do160-environmental-engineer/cli.py +147 -0
  49. package/roles/do160-environmental-engineer/core/do160_environmental_core.py +1246 -0
  50. package/roles/do160-environmental-engineer/templates/environmental-qualification-plan-template.md +114 -0
  51. package/roles/do160-environmental-engineer/tests/test_do160_environmental_core.py +312 -0
  52. package/roles/do160-environmental-engineer/tests/test_role_do160_environmental_engineer.py +281 -0
  53. package/roles/do178c-cert-engineer/ROLE.md +120 -0
  54. package/roles/do178c-cert-engineer/SOURCES.md +16 -0
  55. package/roles/do178c-cert-engineer/cli.py +129 -0
  56. package/roles/do178c-cert-engineer/core/do178c_core.py +411 -0
  57. package/roles/do178c-cert-engineer/templates/psac-template.md +74 -0
  58. package/roles/do178c-cert-engineer/tests/test_do178c_cert_engineer_core.py +91 -0
  59. package/roles/do178c-cert-engineer/tests/test_role_do178c.py +109 -0
  60. package/roles/do254-hardware-engineer/ROLE.md +127 -0
  61. package/roles/do254-hardware-engineer/SOURCES.md +19 -0
  62. package/roles/do254-hardware-engineer/cli.py +308 -0
  63. package/roles/do254-hardware-engineer/core/do254_hardware_core.py +761 -0
  64. package/roles/do254-hardware-engineer/templates/phac-template.md +87 -0
  65. package/roles/do254-hardware-engineer/tests/test_do254_bundle_profile.py +152 -0
  66. package/roles/do254-hardware-engineer/tests/test_do254_hardware_core.py +200 -0
  67. package/roles/do254-hardware-engineer/tests/test_role_do254_hardware.py +108 -0
  68. package/roles/engineering-analysis-engineer/ROLE.md +154 -0
  69. package/roles/engineering-analysis-engineer/SOURCES.md +15 -0
  70. package/roles/engineering-analysis-engineer/cli.py +151 -0
  71. package/roles/engineering-analysis-engineer/core/engineering_analysis_engineer_core.py +866 -0
  72. package/roles/engineering-analysis-engineer/templates/analysis-memo-template.md +73 -0
  73. package/roles/engineering-analysis-engineer/tests/test_engineering_analysis_engineer_core.py +367 -0
  74. package/roles/engineering-analysis-engineer/tests/test_role_engineering_analysis_engineer.py +113 -0
  75. package/roles/flight-management-engineer/ROLE.md +160 -0
  76. package/roles/flight-management-engineer/SOURCES.md +30 -0
  77. package/roles/flight-management-engineer/cli.py +258 -0
  78. package/roles/flight-management-engineer/core/flight_management_core.py +1473 -0
  79. package/roles/flight-management-engineer/templates/route-assessment-template.md +94 -0
  80. package/roles/flight-management-engineer/tests/test_flight_management_core.py +437 -0
  81. package/roles/flight-management-engineer/tests/test_role_flight_management_engineer.py +123 -0
  82. package/roles/flight-mechanics-engineer/ROLE.md +162 -0
  83. package/roles/flight-mechanics-engineer/SOURCES.md +51 -0
  84. package/roles/flight-mechanics-engineer/cli.py +149 -0
  85. package/roles/flight-mechanics-engineer/core/flight_mechanics_core.py +1507 -0
  86. package/roles/flight-mechanics-engineer/templates/perf-sc-report-template.md +82 -0
  87. package/roles/flight-mechanics-engineer/tests/test_flight_mechanics_core.py +277 -0
  88. package/roles/flight-mechanics-engineer/tests/test_role_flight_mechanics_engineer.py +141 -0
  89. package/roles/flight-test-engineer/ROLE.md +137 -0
  90. package/roles/flight-test-engineer/SOURCES.md +24 -0
  91. package/roles/flight-test-engineer/cli.py +135 -0
  92. package/roles/flight-test-engineer/core/flight_test_core.py +931 -0
  93. package/roles/flight-test-engineer/templates/flight-test-template.md +143 -0
  94. package/roles/flight-test-engineer/tests/test_flight_test_engineer_core.py +227 -0
  95. package/roles/flight-test-engineer/tests/test_role_flight_test_engineer.py +150 -0
  96. package/roles/flight-test-performance-engineer/ROLE.md +165 -0
  97. package/roles/flight-test-performance-engineer/SOURCES.md +20 -0
  98. package/roles/flight-test-performance-engineer/cli.py +319 -0
  99. package/roles/flight-test-performance-engineer/core/flight_test_performance_core.py +1398 -0
  100. package/roles/flight-test-performance-engineer/templates/performance-report-template.md +105 -0
  101. package/roles/flight-test-performance-engineer/tests/test_flight_test_performance_engineer_core.py +308 -0
  102. package/roles/flight-test-performance-engineer/tests/test_role_flight_test_performance_engineer.py +165 -0
  103. package/roles/gnc-engineer/ROLE.md +141 -0
  104. package/roles/gnc-engineer/SOURCES.md +35 -0
  105. package/roles/gnc-engineer/cli.py +141 -0
  106. package/roles/gnc-engineer/core/gnc_core.py +1074 -0
  107. package/roles/gnc-engineer/templates/gnc-report-template.md +105 -0
  108. package/roles/gnc-engineer/tests/test_gnc_engineer_core.py +238 -0
  109. package/roles/gnc-engineer/tests/test_role_gnc_engineer.py +142 -0
  110. package/roles/guidance-engineer/ROLE.md +156 -0
  111. package/roles/guidance-engineer/SOURCES.md +43 -0
  112. package/roles/guidance-engineer/cli.py +280 -0
  113. package/roles/guidance-engineer/core/guidance_core.py +820 -0
  114. package/roles/guidance-engineer/templates/guidance-report-template.md +70 -0
  115. package/roles/guidance-engineer/tests/test_guidance_engineer_core.py +269 -0
  116. package/roles/guidance-engineer/tests/test_role_guidance_engineer.py +146 -0
  117. package/roles/high-speed-aerodynamics-engineer/ROLE.md +161 -0
  118. package/roles/high-speed-aerodynamics-engineer/SOURCES.md +18 -0
  119. package/roles/high-speed-aerodynamics-engineer/cli.py +282 -0
  120. package/roles/high-speed-aerodynamics-engineer/core/high_speed_aero_core.py +1566 -0
  121. package/roles/high-speed-aerodynamics-engineer/templates/high-speed-memo-template.md +75 -0
  122. package/roles/high-speed-aerodynamics-engineer/tests/test_bundle_protocol.py +103 -0
  123. package/roles/high-speed-aerodynamics-engineer/tests/test_high_speed_aero_core.py +280 -0
  124. package/roles/high-speed-aerodynamics-engineer/tests/test_role_high_speed_aerodynamics.py +125 -0
  125. package/roles/ndt-engineer/ROLE.md +157 -0
  126. package/roles/ndt-engineer/SOURCES.md +18 -0
  127. package/roles/ndt-engineer/cli.py +316 -0
  128. package/roles/ndt-engineer/core/ndt_core.py +1590 -0
  129. package/roles/ndt-engineer/templates/ndt-plan-template.md +109 -0
  130. package/roles/ndt-engineer/tests/test_ndt_cli_bundle.py +183 -0
  131. package/roles/ndt-engineer/tests/test_ndt_core.py +437 -0
  132. package/roles/ndt-engineer/tests/test_role_ndt_engineer.py +148 -0
  133. package/roles/numerical-analysis-engineer/ROLE.md +158 -0
  134. package/roles/numerical-analysis-engineer/SOURCES.md +19 -0
  135. package/roles/numerical-analysis-engineer/cli.py +414 -0
  136. package/roles/numerical-analysis-engineer/core/numerical_analysis_core.py +1220 -0
  137. package/roles/numerical-analysis-engineer/templates/verification-memo-template.md +120 -0
  138. package/roles/numerical-analysis-engineer/tests/test_bundle_protocol.py +157 -0
  139. package/roles/numerical-analysis-engineer/tests/test_numerical_analysis_core.py +337 -0
  140. package/roles/numerical-analysis-engineer/tests/test_role_numerical_analysis_engineer.py +117 -0
  141. package/roles/propulsion-engineer/ROLE.md +163 -0
  142. package/roles/propulsion-engineer/SOURCES.md +15 -0
  143. package/roles/propulsion-engineer/cli.py +168 -0
  144. package/roles/propulsion-engineer/core/propulsion_core.py +1291 -0
  145. package/roles/propulsion-engineer/templates/propulsion-report-template.md +92 -0
  146. package/roles/propulsion-engineer/tests/test_propulsion_engineer_core.py +294 -0
  147. package/roles/propulsion-engineer/tests/test_role_propulsion_engineer.py +106 -0
  148. package/roles/space-systems-engineer/ROLE.md +161 -0
  149. package/roles/space-systems-engineer/SOURCES.md +49 -0
  150. package/roles/space-systems-engineer/cli.py +173 -0
  151. package/roles/space-systems-engineer/core/space_systems_core.py +980 -0
  152. package/roles/space-systems-engineer/templates/space-systems-template.md +88 -0
  153. package/roles/space-systems-engineer/tests/test_role_space_systems_engineer.py +117 -0
  154. package/roles/space-systems-engineer/tests/test_space_systems_engineer_core.py +247 -0
  155. package/roles/state-estimation-engineer/ROLE.md +154 -0
  156. package/roles/state-estimation-engineer/SOURCES.md +27 -0
  157. package/roles/state-estimation-engineer/cli.py +326 -0
  158. package/roles/state-estimation-engineer/core/state_estimation_core.py +958 -0
  159. package/roles/state-estimation-engineer/templates/nav-state-estimator-design-template.md +76 -0
  160. package/roles/state-estimation-engineer/tests/test_bundle_protocol.py +110 -0
  161. package/roles/state-estimation-engineer/tests/test_role_state_estimation_engineer.py +127 -0
  162. package/roles/state-estimation-engineer/tests/test_state_estimation_engineer_core.py +231 -0
  163. package/roles/structures-loads-engineer/ROLE.md +171 -0
  164. package/roles/structures-loads-engineer/SOURCES.md +44 -0
  165. package/roles/structures-loads-engineer/cli.py +219 -0
  166. package/roles/structures-loads-engineer/core/structures_loads_core.py +1112 -0
  167. package/roles/structures-loads-engineer/templates/loads-strength-report-template.md +96 -0
  168. package/roles/structures-loads-engineer/tests/test_bundle_protocol.py +101 -0
  169. package/roles/structures-loads-engineer/tests/test_role_structures_loads_engineer.py +147 -0
  170. package/roles/structures-loads-engineer/tests/test_structures_loads_core.py +261 -0
@@ -0,0 +1,94 @@
1
+ # Flight Plan and RNAV/RNP Route Assessment
2
+
3
+ **Route:** AERO ROUTE R1 (illustrative - not a filed plan)
4
+ **Aircraft:** Mid-size transport (illustrative example)
5
+ **Cruise level:** FL350 (illustrative profile)
6
+ **Status:** draft-for-review
7
+
8
+ ## 1. Route structure and track distance
9
+
10
+ The assessed route contains 5 waypoints and 4 legs. Waypoints are illustrative identifiers with coordinates; the route is a synthetic example for the role deliverable, not a published navigation database procedure.
11
+
12
+ | Waypoint | Latitude | Longitude | Role |
13
+ |---|---|---|---|
14
+ | W001 | 41.0000 N | 75.0000 W | route origin / start of RNP segment |
15
+ | W002 | 40.0000 N | 74.5000 W | RF leg entry fix |
16
+ | W003 | 39.7418 N | 74.6499 W | derived: RF leg exit fix (computed) |
17
+ | HOLDF | 38.7418 N | 74.6499 W | derived: holding fix (computed from W003) |
18
+ | W005 | 38.0000 N | 75.0000 W | RTA fix / VNAV crossing constraint |
19
+
20
+ | Leg | Type | From | To | Track (deg)* | Distance (NM) |
21
+ |---|---|---|---|---|---|
22
+ *TF legs: initial great-circle track. RF leg: exit track after the turn (the value shown is the flight tangent at the exit fix).*
23
+
24
+ | 1 | TF (great-circle) | W001 | W002 | 159.0 deg | 64.23 |
25
+ | 2 | RF (radius-to-fix) | W002 | W003 | 249.0 deg | 18.85 |
26
+ | 3 | TF (great-circle) | W003 | HOLDF | 180.0 deg | 60.04 |
27
+ | 4 | TF (great-circle) | HOLDF | W005 | 200.4 deg | 47.49 |
28
+
29
+ Total track distance: **190.61 NM**.
30
+
31
+ Leg 1 track-distance method check (rhumb line vs great circle): rhumb 64.233 NM, great-circle 64.233 NM, difference 0.2 m (0.000 %), rhumb course 159.2 deg. Great-circle tracks are used for the TF legs.
32
+
33
+ ## 2. Radius-to-fix (RF) leg construction (leg 2)
34
+
35
+ The RF leg from W002 turns RIGHT at radius 12.0 NM with the published swept angle 90.0 deg. Entry fix is the origin of the leg local tangent frame (x = east, y = north, NM).
36
+
37
+ - Turn centre (local NM): (-11.204, -4.296)
38
+ - Exit fix (local NM): (-6.908, -15.501) (on radius circle: True)
39
+ - Swept angle: 90.0 deg; arc length: 18.85 NM
40
+ - Exit track: 249.0 deg; chord: 16.97 NM
41
+ - RF leg valid: True
42
+
43
+ ## 3. RNP containment on the RNP segment
44
+
45
+ Segment: W002 - HOLDF - W005 (en-route/arrival RNAV portion)
46
+ - Required navigation performance (RNP): 0.3 NM = 555.6 m
47
+ - Lateral 1-sigma position error input: 200 m
48
+ - Actual navigation performance (ANP), 95% containment (2 x sigma): 400.0 m (0.2160 NM)
49
+ - Required margin (10 % of RNP): 55.6 m
50
+ - Containment check (ANP + margin <= RNP): **PASS** - margin available 100.0 m
51
+
52
+ ## 4. Holding pattern entry at HOLDF
53
+
54
+ Approach track at the holding fix: 180.0 deg (leg 3, due-south meridian). Hold inbound course 260.0 deg, right turns. Approach angle alpha to the holding axis: 80.0 deg (sector rule: direct <= 70 deg, teardrop 70 < alpha <= 110 deg, parallel > 110 deg).
55
+
56
+ - Entry type: **teardrop**
57
+ - Outbound leg timing at FL200 (20000 ft): 90 s
58
+ - Outbound heading 80.0 deg, wind 40.0 deg/30 kt, TAS 260 kt: 1-in-60 corrected heading **75.55 deg**
59
+ - First (entry) lap estimate: 330 s
60
+
61
+ ## 5. RTA time control at W005
62
+
63
+ RTA evaluated from the HOLDF exit, 47.5 NM before W005, slot demand 25 s later than the nominal arrival.
64
+ - Current ground speed: 240.0 m/s; nominal ETA: 366.5 s; RTA time error: -25.0 s
65
+ - Required ground speed: 224.7 m/s; required Mach: 0.7267
66
+ - Commanded Mach: **0.7267**; verdict: **rta-feasible** (feasible: True)
67
+ - Achievable window at 20000 ft: [346 s, 407 s] (61 s wide)
68
+
69
+ ## 6. Performance and vertical (VNAV) profile
70
+
71
+ ECON cruise at FL350, cost index 30 kg/h, weight 62000 kg:
72
+ - ECON Mach: **0.7723** (TAS 445.2 kt, fuel 5.323 kg/NM); envelope [0.70, 0.82] Mach; max-range reference (CI 0): Mach 0.7706, fuel 5.323 kg/NM
73
+
74
+ Vertical path: descend FL350 -> FL200 on a 3.0 deg flight path (318.4 ft/NM) to cross HOLDF AT FL200, then continue toward W005 (constraint AT 6000 ft).
75
+ - Top of descent (air): 47.1 NM before HOLDF; with 15 kt headwind at 450 kt TAS: 48.7 NM ground distance, starting 11.3 NM inside leg 3 (60.04 NM) - no path conflict.
76
+ - Crossing altitude at W005 continuing the 3.0 deg path: 4877 ft vs constraint AT 6000 ft: **FAIL**
77
+ - Gradient required to comply: 295 ft/NM (2.78 deg)
78
+ - Vertical band checks: cruise FL350 within band: PASS; HOLDF crossing FL200 within band: PASS
79
+
80
+ ## 7. DME arc and radio navaid geometry checks
81
+
82
+ Published arc transition check: radius 15 NM from the arc navaid between radials 30 and 90:
83
+ - Arc length: 15.71 NM; chord: 15.00 NM; turn angle: 60.0 deg
84
+ - Bank angle to hold the arc at 250 kt TAS: 3.47 deg; turn radius at 25 deg bank: 1.95 NM
85
+ - DME slant range at 15 NM ground range, FL200: 28441 m (planar ground range 27780 m); arc start point bearing 30.0 deg, reciprocal radial 210.0 deg
86
+
87
+ ## 8. Findings and recommendations
88
+
89
+ - **[FINDING]** W005 crossing constraint not met on the planned 3.0 deg path - Continuing the 3.0 deg descent path from the FL200 hold crossing reaches W005 at 4877 ft, below the AT 6000 ft constraint by 1123 ft. Replan the arrival profile: the descent leg from the hold to W005 requires a gradient no steeper than 295 ft/NM (2.78 deg).
90
+
91
+ Recommendation: resolve every [FINDING] before the route is offered for operational dispatch review. Numerical values in this assessment are computed by the role core from the stated route facts and the bound flight-management logic leaves.
92
+
93
+ ---
94
+ *Generated by Aero Agent Roles flight-management-engineer core (2026-09-05). DRAFT for human flight operations / dispatch review. This document is not a clearance, not a filed flight plan, and not an approval; the operator and the flight crew retain all operational decision authority.*
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env python3
2
+ """Test flight_management_core: the executable engine of the
3
+ Flight Management Engineer role.
4
+
5
+ Proves the role can DO its job standalone (no AeroSkills needed) and
6
+ that the domain numbers are real: RNP containment (ANP = 2 x sigma,
7
+ 95% containment), the 70/110 holding-entry sector rule, outbound leg
8
+ timing and the 1-in-60 wind correction (leaf worked anchor 94.7140),
9
+ RF leg construction (radius circle, swept angle, exit track, chord),
10
+ DME arc geometry and bank angle, great-circle/rhumb anchors (1 deg on
11
+ the equator = R*pi/180), the RTA speed-command law, VNAV top-of-descent
12
+ and gradients, the ECON cruise clamp, radio navaid slant range, plus
13
+ the builder, the evidence gates and standalone mode.
14
+ """
15
+ import json
16
+ import math
17
+ import os
18
+ import sys
19
+ import unittest
20
+
21
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
22
+ "..", "core"))
23
+ from flight_management_core import ( # noqa: E402
24
+ NM_TO_M, G_MS2, R_EARTH_M,
25
+ anp_from_sigma, build_report, check_report, check_report_markdown,
26
+ containment_analysis, containment_margin_m, containment_pass,
27
+ dme_arc_bank_angle_deg, dme_arc_chord_nm, dme_arc_geometry,
28
+ dme_arc_length_nm, dme_arc_turn_radius_nm, example_item,
29
+ example_report_markdown, fpa_gradient_ft_nm, great_circle_distance_m,
30
+ great_circle_track_deg, hold_alpha_deg, hold_entry_analysis,
31
+ holding_entry_lap_seconds, holding_entry_type, isa_speed_of_sound_m_s,
32
+ margin_available_m, nav_bearing_deg, nav_dme_slant_range_m,
33
+ nav_radial_deg, outbound_leg_seconds, perf_cost_index,
34
+ perf_econ_mach_from_cost_index, perf_econ_summary,
35
+ perf_max_range_speed_kts, perf_tas_from_mach, render_report_markdown,
36
+ rf_leg_geometry, rhumb_course_deg, rhumb_distance_m,
37
+ rhumb_vs_great_circle, rnp_nm_to_m, rta_achievable_window,
38
+ rta_eta_s, rta_required_ground_speed_m_s, rta_speed_command,
39
+ rta_time_error_s, vertical_band_ok, vnav_altitude_at_ft,
40
+ vnav_constraint_ok, vnav_descent_gradient_ft_nm, vnav_fpa_deg,
41
+ vnav_tod_distance_nm, wind_corrected_heading,
42
+ )
43
+
44
+
45
+ class TestRnpContainment(unittest.TestCase):
46
+ """ANP = 2 x 1-sigma (95% containment), margin and verdict rules."""
47
+
48
+ def test_anp_two_sigma(self):
49
+ self.assertAlmostEqual(anp_from_sigma(92.6), 185.2, places=3)
50
+ self.assertAlmostEqual(anp_from_sigma(200.0), 400.0, places=3)
51
+
52
+ def test_rnp_nm_to_m(self):
53
+ self.assertAlmostEqual(rnp_nm_to_m(0.3), 0.3 * 1852.0, places=6)
54
+ self.assertAlmostEqual(rnp_nm_to_m(0.3), 555.6, places=1)
55
+
56
+ def test_margin_and_available(self):
57
+ self.assertAlmostEqual(containment_margin_m(555.6, 0.10), 55.56,
58
+ places=6)
59
+ self.assertAlmostEqual(margin_available_m(185.2, 555.6, 0.10),
60
+ 555.6 - 55.56 - 185.2, places=6)
61
+
62
+ def test_containment_pass_inclusive(self):
63
+ # boundary inclusive: anp == rnp passes with zero margin
64
+ self.assertTrue(containment_pass(555.6, 555.6, 0.0))
65
+ self.assertTrue(containment_pass(185.2, 555.6, 0.10))
66
+ self.assertFalse(containment_pass(556.0, 555.6, 0.0))
67
+
68
+ def test_analysis_verdicts(self):
69
+ ok = containment_analysis(sigma_lateral_m=200.0, rnp_m=555.6,
70
+ margin_fraction=0.10)
71
+ self.assertTrue(ok["pass"])
72
+ self.assertEqual(ok["verdict"], "PASS")
73
+ bad = containment_analysis(sigma_lateral_m=400.0, rnp_m=555.6,
74
+ margin_fraction=0.10)
75
+ self.assertFalse(bad["pass"])
76
+ self.assertEqual(bad["verdict"], "FAIL")
77
+
78
+ def test_analysis_anp_direct(self):
79
+ r = containment_analysis(anp_m=400.0, rnp_m=555.6, margin_fraction=0.0)
80
+ self.assertAlmostEqual(r["anp_m"], 400.0, places=3)
81
+
82
+
83
+ class TestHoldingEntry(unittest.TestCase):
84
+ """70/110 sector rule, outbound timing, 1-in-60 wind correction."""
85
+
86
+ def test_alpha_arithmetic(self):
87
+ self.assertAlmostEqual(hold_alpha_deg(180.0, 260.0), 80.0, places=6)
88
+ self.assertAlmostEqual(hold_alpha_deg(90.0, 90.0), 0.0, places=6)
89
+ self.assertAlmostEqual(hold_alpha_deg(90.0, 270.0), 180.0, places=6)
90
+ self.assertAlmostEqual(hold_alpha_deg(350.0, 10.0), 20.0, places=6)
91
+ self.assertAlmostEqual(hold_alpha_deg(30.0, 90.0), 60.0, places=6)
92
+
93
+ def test_entry_sector_boundaries(self):
94
+ self.assertEqual(holding_entry_type(0.0, "right"), "direct")
95
+ self.assertEqual(holding_entry_type(70.0, "right"), "direct")
96
+ self.assertEqual(holding_entry_type(70.001, "right"), "teardrop")
97
+ self.assertEqual(holding_entry_type(90.0, "right"), "teardrop")
98
+ self.assertEqual(holding_entry_type(110.0, "right"), "teardrop")
99
+ self.assertEqual(holding_entry_type(110.001, "right"), "parallel")
100
+ self.assertEqual(holding_entry_type(180.0, "right"), "parallel")
101
+
102
+ def test_left_hand_mirrors(self):
103
+ for alpha in (50.0, 90.0, 130.0, 70.0, 110.0):
104
+ self.assertEqual(holding_entry_type(alpha, "left"),
105
+ holding_entry_type(alpha, "right"))
106
+
107
+ def test_entry_valueerrors(self):
108
+ with self.assertRaises(ValueError):
109
+ holding_entry_type(-1.0, "right")
110
+ with self.assertRaises(ValueError):
111
+ holding_entry_type(181.0, "right")
112
+ with self.assertRaises(ValueError):
113
+ holding_entry_type(50.0, "bank")
114
+
115
+ def test_outbound_leg_timing(self):
116
+ self.assertEqual(outbound_leg_seconds(12000.0), 60.0)
117
+ self.assertEqual(outbound_leg_seconds(14000.0), 60.0)
118
+ self.assertEqual(outbound_leg_seconds(14001.0), 90.0)
119
+ self.assertEqual(outbound_leg_seconds(20000.0), 90.0)
120
+
121
+ def test_wind_correction_leaf_anchor(self):
122
+ # Worked anchor from the holding-pattern-entry leaf: 4.71 deg at
123
+ # outbound 090, wind from 135/20 kt, TAS 180 kt.
124
+ corr, raw = wind_corrected_heading(90.0, 135.0, 20.0, 180.0)
125
+ self.assertAlmostEqual(corr, 94.7140, places=3)
126
+ self.assertAlmostEqual(raw, 4.7140, places=3)
127
+ corr2, raw2 = wind_corrected_heading(90.0, 315.0, 20.0, 180.0)
128
+ self.assertAlmostEqual(corr + corr2, 180.0, places=6)
129
+ self.assertAlmostEqual(raw2, -4.7140, places=3)
130
+
131
+ def test_entry_lap_times(self):
132
+ self.assertEqual(holding_entry_lap_seconds("direct", 60.0), 240.0)
133
+ self.assertEqual(holding_entry_lap_seconds("teardrop", 90.0), 330.0)
134
+ self.assertEqual(holding_entry_lap_seconds("parallel", 60.0), 360.0)
135
+
136
+ def test_full_hold_analysis(self):
137
+ h = hold_entry_analysis(arrival_track_deg=180.0,
138
+ hold_inbound_course_deg=260.0,
139
+ turn_direction="right", altitude_ft=20000.0,
140
+ outbound_heading_deg=80.0,
141
+ wind_from_deg=40.0, wind_speed_kt=30.0,
142
+ tas_kt=260.0)
143
+ self.assertEqual(h["entry"], "teardrop")
144
+ self.assertAlmostEqual(h["alpha_deg"], 80.0, places=1)
145
+ self.assertEqual(h["outbound_leg_s"], 90.0)
146
+ self.assertEqual(h["entry_lap_s"], 330.0)
147
+
148
+
149
+ class TestLateralGeometry(unittest.TestCase):
150
+ """Great-circle and rhumb anchors (R = 6371000 m)."""
151
+
152
+ def test_great_circle_equator_degree(self):
153
+ # 1 deg of longitude at the equator = R * pi/180
154
+ expected = R_EARTH_M * math.pi / 180.0
155
+ self.assertAlmostEqual(great_circle_distance_m(0.0, 0.0, 0.0, 1.0),
156
+ expected, places=1)
157
+
158
+ def test_great_circle_meridian_degree(self):
159
+ self.assertAlmostEqual(great_circle_distance_m(0.0, 0.0, 1.0, 0.0),
160
+ R_EARTH_M * math.pi / 180.0, places=1)
161
+
162
+ def test_great_circle_tracks(self):
163
+ self.assertAlmostEqual(great_circle_track_deg(0.0, 0.0, 0.0, 10.0),
164
+ 90.0, places=6)
165
+ self.assertAlmostEqual(great_circle_track_deg(0.0, 0.0, 10.0, 0.0),
166
+ 0.0, places=6)
167
+ self.assertAlmostEqual(great_circle_track_deg(0.0, 0.0, -10.0, 0.0),
168
+ 180.0, places=6)
169
+
170
+ def test_rhumb_meridian_equals_gc(self):
171
+ r = rhumb_vs_great_circle(40.0, -75.0, 41.0, -75.0)
172
+ self.assertAlmostEqual(r["delta_m"], 0.0, places=6)
173
+ self.assertEqual(rhumb_course_deg(0.0, 0.0, 10.0, 0.0), 0.0)
174
+ self.assertEqual(rhumb_course_deg(0.0, 0.0, 0.0, 10.0), 90.0)
175
+
176
+ def test_rhumb_parallel_at_60(self):
177
+ # along-parallel at lat 60 over 1 deg: R*rad(1)*cos(60)
178
+ expected = 6371000.0 * math.radians(1.0) * math.cos(math.radians(60.0))
179
+ self.assertAlmostEqual(rhumb_distance_m(60.0, 0.0, 60.0, 1.0),
180
+ expected, places=1)
181
+
182
+ def test_rhumb_longer_than_gc(self):
183
+ r = rhumb_vs_great_circle(30.0, -70.0, 32.0, -65.0)
184
+ self.assertGreaterEqual(r["delta_m"], 0.0)
185
+ self.assertGreater(r["great_circle_m"], 0.0)
186
+
187
+
188
+ class TestRfLeg(unittest.TestCase):
189
+ """RF construction anchors: EF(0,0), inbound 090, R=15 NM, RIGHT."""
190
+
191
+ def test_rf_geometry_anchor(self):
192
+ g = rf_leg_geometry((0.0, 0.0), 90.0, 15.0, "RIGHT", 90.0)
193
+ self.assertAlmostEqual(g["center_nm"][0], 0.0, places=3)
194
+ self.assertAlmostEqual(g["center_nm"][1], -15.0, places=3)
195
+ self.assertAlmostEqual(g["xf"][0], 15.0, places=3)
196
+ self.assertAlmostEqual(g["xf"][1], -15.0, places=3)
197
+ self.assertTrue(g["exit_on_arc"])
198
+ self.assertAlmostEqual(g["sweep_deg"], 90.0, places=6)
199
+ self.assertAlmostEqual(g["arc_length_nm"], 15.0 * math.pi / 2.0,
200
+ places=3)
201
+ self.assertAlmostEqual(g["chord_nm"], 15.0 * math.sqrt(2.0), places=3)
202
+ self.assertAlmostEqual(g["exit_track_deg"], 180.0, places=6)
203
+ self.assertTrue(g["valid"])
204
+
205
+ def test_rf_left_mirror(self):
206
+ g = rf_leg_geometry((0.0, 0.0), 90.0, 15.0, "LEFT", 90.0)
207
+ self.assertAlmostEqual(g["center_nm"][1], 15.0, places=3)
208
+ self.assertAlmostEqual(g["exit_track_deg"], 0.0, places=6)
209
+
210
+
211
+ class TestDmeArc(unittest.TestCase):
212
+ """DME arc length/chord/bank: r=15 NM over 60 deg of radial."""
213
+
214
+ def test_arc_length_chord(self):
215
+ self.assertAlmostEqual(dme_arc_length_nm(15.0, 60.0),
216
+ 15.0 * math.pi / 3.0, places=3)
217
+ self.assertAlmostEqual(dme_arc_chord_nm(15.0, 60.0), 15.0, places=3)
218
+
219
+ def test_arc_bank_angle(self):
220
+ # bank = atan(V^2/(g*r)), V = 250 kt, r = 15 NM in SI
221
+ v = 250.0 * 0.514444
222
+ r_m = 15.0 * NM_TO_M
223
+ expected = math.degrees(math.atan(v * v / (G_MS2 * r_m)))
224
+ self.assertAlmostEqual(dme_arc_bank_angle_deg(250.0, 15.0),
225
+ expected, places=6)
226
+ self.assertAlmostEqual(dme_arc_bank_angle_deg(250.0, 15.0),
227
+ 3.475, places=2)
228
+
229
+ def test_turn_radius(self):
230
+ v = 250.0 * 0.514444
231
+ r_m = v * v / (G_MS2 * math.tan(math.radians(25.0)))
232
+ self.assertAlmostEqual(dme_arc_turn_radius_nm(250.0, 25.0),
233
+ r_m / NM_TO_M, places=6)
234
+
235
+ def test_arc_geometry(self):
236
+ g = dme_arc_geometry(15.0, 30.0, 90.0)
237
+ self.assertAlmostEqual(g["turn_angle_deg"], 60.0, places=6)
238
+ self.assertAlmostEqual(g["arc_length_nm"], 15.0 * math.pi / 3.0,
239
+ places=3)
240
+
241
+
242
+ class TestRta(unittest.TestCase):
243
+ """RTA law anchors (SI)."""
244
+
245
+ def test_eta(self):
246
+ self.assertEqual(rta_eta_s(200000.0, 250.0), 800.0)
247
+ with self.assertRaises(ValueError):
248
+ rta_eta_s(200000.0, 0.0)
249
+
250
+ def test_required_ground_speed(self):
251
+ self.assertEqual(rta_required_ground_speed_m_s(100000.0, 500.0, 0.0),
252
+ 200.0)
253
+
254
+ def test_time_error(self):
255
+ self.assertEqual(rta_time_error_s(800.0, 800.0, 0.0), 0.0)
256
+ self.assertEqual(rta_time_error_s(800.0, 750.0, 0.0), 50.0)
257
+
258
+ def test_isa_speed_of_sound_sea_level(self):
259
+ expected = math.sqrt(1.4 * 287.05 * 288.15)
260
+ self.assertAlmostEqual(isa_speed_of_sound_m_s(0.0), expected, places=2)
261
+ self.assertAlmostEqual(isa_speed_of_sound_m_s(0.0), 340.29, places=1)
262
+
263
+ def test_feasible_slowdown(self):
264
+ # Route-shaped case: FL200 (a ~316 m/s), demand 25 s late.
265
+ alt = 20000.0 * 0.3048
266
+ a = isa_speed_of_sound_m_s(alt)
267
+ eta = rta_eta_s(87952.0, 240.0)
268
+ rta = rta_speed_command(remaining_distance_m=87952.0,
269
+ ground_speed_m_s=240.0,
270
+ wind_along_m_s=-5.0,
271
+ rta_time_s=eta + 25.0, altitude_m=alt)
272
+ self.assertTrue(rta["feasible"])
273
+ self.assertEqual(rta["verdict"], "rta-feasible")
274
+ expected_req = 87952.0 / (eta + 25.0)
275
+ self.assertAlmostEqual(rta["required_gs_m_s"], expected_req, places=2)
276
+ expected_mach = (expected_req + 5.0) / a
277
+ self.assertAlmostEqual(rta["command_mach"], expected_mach, places=4)
278
+ self.assertAlmostEqual(rta["remaining_error_s"], 0.0, places=6)
279
+
280
+ def test_unfeasible_high_demand(self):
281
+ alt = 20000.0 * 0.3048
282
+ eta = rta_eta_s(87952.0, 240.0)
283
+ rta = rta_speed_command(remaining_distance_m=87952.0,
284
+ ground_speed_m_s=240.0,
285
+ wind_along_m_s=-5.0,
286
+ rta_time_s=max(1.0, eta - 100.0),
287
+ altitude_m=alt)
288
+ self.assertFalse(rta["feasible"])
289
+ self.assertEqual(rta["verdict"], "rta-unfeasible")
290
+ self.assertEqual(rta["command_mach"], 0.82)
291
+
292
+ def test_window(self):
293
+ alt = 20000.0 * 0.3048
294
+ w = rta_achievable_window(87952.0, alt, -5.0, 0.70, 0.82)
295
+ self.assertGreater(w["eta_min_s"], 0.0)
296
+ self.assertGreater(w["eta_max_s"], w["eta_min_s"])
297
+
298
+
299
+ class TestVnavAndPerf(unittest.TestCase):
300
+ """Top of descent, gradient/FPA math, ECON clamps, band checks."""
301
+
302
+ def test_tod(self):
303
+ self.assertAlmostEqual(vnav_tod_distance_nm(35000.0, 20000.0, 318.4),
304
+ 15000.0 / 318.4, places=4)
305
+
306
+ def test_fpa_roundtrip(self):
307
+ grad = fpa_gradient_ft_nm(3.0)
308
+ self.assertAlmostEqual(vnav_fpa_deg(grad), 3.0, places=6)
309
+ self.assertAlmostEqual(grad,
310
+ math.tan(math.radians(3.0)) * 6076.1154,
311
+ places=3)
312
+
313
+ def test_gradient_and_altitude(self):
314
+ g = vnav_descent_gradient_ft_nm(20000.0, 6000.0, 47.49)
315
+ self.assertAlmostEqual(g, 14000.0 / 47.49, places=3)
316
+ alt = vnav_altitude_at_ft(20000.0, g, 47.49)
317
+ self.assertAlmostEqual(alt, 6000.0, places=0)
318
+
319
+ def test_constraint_verdicts(self):
320
+ self.assertTrue(vnav_constraint_ok(6005.0, 6000.0, False, tol_ft=20.0))
321
+ self.assertFalse(vnav_constraint_ok(5194.0, 6000.0, False,
322
+ tol_ft=20.0))
323
+ self.assertTrue(vnav_constraint_ok(5194.0, 5000.0, True))
324
+
325
+ def test_vertical_bands(self):
326
+ self.assertTrue(vertical_band_ok(35000.0, 33000.0, 39000.0))
327
+ self.assertTrue(vertical_band_ok(20000.0, 19500.0, 20500.0))
328
+ self.assertFalse(vertical_band_ok(12000.0, 20000.0, 20000.0))
329
+
330
+ def test_cost_index(self):
331
+ self.assertEqual(perf_cost_index(60.0, 1.0), 60.0)
332
+ self.assertEqual(perf_cost_index(0.0, 2.0), 0.0)
333
+
334
+ def test_econ_clamped_and_positive(self):
335
+ for ci in (0.0, 30.0, 100.0):
336
+ m = perf_econ_mach_from_cost_index(ci, 62000.0, 35000.0)
337
+ self.assertGreaterEqual(m, 0.70)
338
+ self.assertLessEqual(m, 0.82)
339
+ self.assertGreater(perf_max_range_speed_kts(62000.0, 35000.0), 0.0)
340
+
341
+ def test_mach_tas_roundtrip(self):
342
+ tas = perf_tas_from_mach(0.78, 35000.0)
343
+ mach_back = tas / (math.sqrt(1.4 * 287.05
344
+ * (288.15 - 0.0065 * 35000.0 * 0.3048))
345
+ * 1.943844492)
346
+ self.assertAlmostEqual(mach_back, 0.78, places=6)
347
+
348
+ def test_econ_summary_shape(self):
349
+ s = perf_econ_summary(30.0, 62000.0, 35000.0)
350
+ self.assertIn("mach", s)
351
+ self.assertGreater(s["fuel_per_nm_kg"], 0.0)
352
+
353
+
354
+ class TestNavaidGeometry(unittest.TestCase):
355
+ """DME slant range and VOR bearing/radial conversions."""
356
+
357
+ def test_slant_range(self):
358
+ # 15 NM ground range at FL200: sqrt(27780^2 + 6096^2)
359
+ expected = math.hypot(15.0 * NM_TO_M, 20000.0 * 0.3048)
360
+ self.assertAlmostEqual(nav_dme_slant_range_m(15.0 * NM_TO_M, 0.0,
361
+ 20000.0 * 0.3048),
362
+ expected, places=1)
363
+
364
+ def test_bearing_radial(self):
365
+ self.assertEqual(nav_bearing_deg(0.0, 10.0), 0.0)
366
+ self.assertEqual(nav_bearing_deg(10.0, 0.0), 90.0)
367
+ self.assertEqual(nav_radial_deg(30.0), 210.0)
368
+ self.assertEqual(nav_radial_deg(210.0), 30.0)
369
+
370
+
371
+ class TestBuilderAndGates(unittest.TestCase):
372
+
373
+ def test_example_route_facts(self):
374
+ model = build_report(example_item())
375
+ self.assertEqual(len(model["legs"]), 4)
376
+ self.assertEqual(len(model["waypoints"]), 5)
377
+ self.assertEqual(model["hold"]["entry"], "teardrop")
378
+ self.assertEqual(model["rnp"]["rnp_nm"], 0.3)
379
+ self.assertTrue(model["rnp"]["containment"]["pass"])
380
+ self.assertTrue(model["rta"]["feasible"])
381
+ # example deliberately surfaces the W005 crossing finding
382
+ self.assertFalse(model["vnav"]["w005_crossing_ok"])
383
+ self.assertGreaterEqual(model["n_findings"], 1)
384
+ self.assertEqual(model["status"], "draft-for-review")
385
+ self.assertEqual(model["total_distance_nm"], sum(
386
+ leg["distance_nm"] for leg in model["legs"]))
387
+
388
+ def test_core_gates_all_pass(self):
389
+ model = build_report(example_item())
390
+ gates = check_report(model)
391
+ self.assertTrue(gates["all_pass"], gates)
392
+ for gate in ("route_stated", "rnp_present", "containment_checked",
393
+ "entries_correct", "sign_off_honest"):
394
+ self.assertTrue(gates[gate], gate)
395
+
396
+ def test_containment_fail_still_gate_checked(self):
397
+ item = example_item()
398
+ item.sigma_lateral_m = 400.0 # ANP 800 m > RNP 555.6 m + margin
399
+ model = build_report(item)
400
+ self.assertFalse(model["rnp"]["containment"]["pass"])
401
+ self.assertEqual(model["rnp"]["containment"]["verdict"], "FAIL")
402
+ gates = check_report(model)
403
+ self.assertTrue(gates["all_pass"], gates) # structural gates
404
+
405
+ def test_markdown_gates_all_pass(self):
406
+ md = example_report_markdown()
407
+ gates = check_report_markdown(md)
408
+ self.assertTrue(gates["all_pass"], gates)
409
+ self.assertGreater(len(md), 2000)
410
+
411
+ def test_markdown_sections_present(self):
412
+ md = example_report_markdown()
413
+ for n in range(1, 9):
414
+ self.assertIn("## %d. " % n, md, "section %d missing" % n)
415
+ low = md.lower()
416
+ self.assertIn("not an approval", low)
417
+ self.assertIn("draft", low)
418
+ self.assertIn("teardrop", low)
419
+
420
+ def test_model_plain_json(self):
421
+ # protocol: model is plain JSON (no NaN / non-serializable objects)
422
+ model = build_report(example_item())
423
+ text = json.dumps(model)
424
+ self.assertGreater(len(text), 1500)
425
+ self.assertNotIn("NaN", text)
426
+
427
+ def test_standalone_no_skills_repo(self):
428
+ # Core must work with no AeroSkills present.
429
+ os.environ["AEROSKILLS_DEV"] = "/nonexistent"
430
+ model = build_report(example_item())
431
+ self.assertTrue(model["rnp"]["containment"]["verdict"] in ("PASS", "FAIL"))
432
+ md = render_report_markdown(model)
433
+ self.assertGreater(len(md), 2000)
434
+
435
+
436
+ if __name__ == "__main__":
437
+ unittest.main()
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Role test: Flight Management Engineer.
3
+
4
+ Verifies (offline, no network):
5
+ 1. Every skill bound in ROLE.md frontmatter RESOLVES to a real leaf in
6
+ the Aero Agent Skills repo (avionics/flight-management cluster).
7
+ 2. The workflow stage order is deterministic and complete.
8
+ 3. The filled deliverable template is complete: numbered sections,
9
+ DRAFT marker, not-an-approval boundary, no blank placeholders.
10
+ 4. Boundaries, author, SOURCES.md and the executable core/cli anatomy.
11
+ """
12
+ import os
13
+ import re
14
+ import sys
15
+ import unittest
16
+
17
+ ROLES_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
18
+ os.path.abspath(__file__))))) # repo root (4 up from tests/)
19
+ AEROSKILLS = os.environ.get("AEROSKILLS_DEV", os.path.expanduser("~/AeroSkills"))
20
+ HAS_SKILLS = os.path.isdir(os.path.join(AEROSKILLS, "skills"))
21
+ ROLE_DIR = os.path.join(ROLES_REPO, "roles", "flight-management-engineer")
22
+ TEMPLATE = os.path.join(ROLE_DIR, "templates", "route-assessment-template.md")
23
+
24
+ EXPECTED_BOUND = [
25
+ "avionics/flight-management/flight-planning",
26
+ "avionics/flight-management/lateral-navigation",
27
+ "avionics/flight-management/vertical-navigation",
28
+ "avionics/flight-management/holding-pattern-entry",
29
+ "avionics/flight-management/dme-arc-leg",
30
+ "avionics/flight-management/radius-to-fix-leg",
31
+ "avionics/flight-management/rhumb-line-leg",
32
+ "avionics/flight-management/radio-navigation-aids",
33
+ "avionics/flight-management/rnp-anp-containment",
34
+ "avionics/flight-management/performance-computation",
35
+ "avionics/flight-management/rta-time-control",
36
+ ]
37
+
38
+ EXPECTED_STAGES = [
39
+ "Route structure & constraints",
40
+ "Lateral track geometry",
41
+ "RF leg construction",
42
+ "Rhumb line legs",
43
+ "RNP containment",
44
+ "Holding entry",
45
+ "DME arc legs",
46
+ "Navaid geometry",
47
+ "RTA control",
48
+ "VNAV & performance",
49
+ "Findings & sign-off",
50
+ ]
51
+
52
+
53
+ def read_role_md():
54
+ p = os.path.join(ROLE_DIR, "ROLE.md")
55
+ if not os.path.exists(p):
56
+ return None
57
+ return open(p).read()
58
+
59
+
60
+ class TestFlightManagementRole(unittest.TestCase):
61
+
62
+ def test_bound_skills_resolve_in_aeroskills(self):
63
+ if not HAS_SKILLS:
64
+ self.skipTest("Aero Agent Skills checkout not present "
65
+ "(cross-repo dev check)")
66
+ for leaf in EXPECTED_BOUND:
67
+ sk = os.path.join(AEROSKILLS, "skills", leaf, "SKILL.md")
68
+ self.assertTrue(os.path.exists(sk),
69
+ "bound skill not found in aero-agent-skills: %s"
70
+ % leaf)
71
+
72
+ def test_bound_skills_listed_in_frontmatter(self):
73
+ role = read_role_md()
74
+ self.assertIsNotNone(role)
75
+ for leaf in EXPECTED_BOUND:
76
+ self.assertIn(leaf, role, "missing in ROLE.md skills_bound: %s"
77
+ % leaf)
78
+
79
+ def test_workflow_stages_deterministic(self):
80
+ role = read_role_md()
81
+ idx = [role.find(stage) for stage in EXPECTED_STAGES]
82
+ self.assertTrue(all(i >= 0 for i in idx), "a stage is missing")
83
+ self.assertEqual(idx, sorted(idx),
84
+ "stage order not deterministic")
85
+
86
+ def test_deliverable_template_complete(self):
87
+ if not os.path.exists(TEMPLATE):
88
+ self.fail("route-assessment-template.md missing")
89
+ text = open(TEMPLATE).read()
90
+ # all 8 numbered sections present
91
+ for n in range(1, 9):
92
+ self.assertTrue(re.search(r"^## %d\. " % n, text, re.M),
93
+ "section %d missing" % n)
94
+ self.assertIn("DRAFT", text)
95
+ low = text.lower()
96
+ self.assertIn("not an approval", low)
97
+ self.assertIn("document", low)
98
+ # zero blank placeholders
99
+ self.assertNotRegex(text, r"_{3,}|\[TBD\]|TODO|Lorem")
100
+
101
+ def test_forbidden_lines_present(self):
102
+ role = read_role_md()
103
+ for phrase in ["approve the flight plan", "issue a clearance",
104
+ "reproduce proprietary", "sign_off_required: true"]:
105
+ self.assertIn(phrase, role, "missing boundary: %s" % phrase)
106
+
107
+ def test_author_ashfordeou(self):
108
+ role = read_role_md()
109
+ self.assertIn("ashfordeOU", role, "author not set")
110
+
111
+ def test_sources_register_exists(self):
112
+ p = os.path.join(ROLE_DIR, "SOURCES.md")
113
+ self.assertTrue(os.path.exists(p), "SOURCES.md missing for role")
114
+
115
+ def test_core_engine_exists(self):
116
+ """100% standard: every role ships an executable core + cli."""
117
+ for f in ["core", "cli.py"]:
118
+ self.assertTrue(os.path.exists(os.path.join(ROLE_DIR, f)),
119
+ "%s missing - role is not executable" % f)
120
+
121
+
122
+ if __name__ == "__main__":
123
+ unittest.main()