"""Tests for the constraint resolver engine.""" from physcom.engine.constraint_resolver import ConstraintResolver from physcom.models.combination import Combination from physcom.models.domain import DomainConstraint from physcom.models.entity import Entity, Dependency def test_compatible_ground_combo(bicycle, human_pedalling, food_calories): """Bicycle + Human Pedalling + Food/Calories should be valid.""" resolver = ConstraintResolver() combo = Combination(entities=[bicycle, human_pedalling, food_calories]) result = resolver.resolve(combo) assert result.status != "p1_fail", f"Unexpected block: {result.violations}" def test_solar_sail_blocks_with_road_vehicle(road_vehicle, solar_sail, solar_radiation): """Road Vehicle (ground) + Solar Sail (space) should be blocked by medium mutex.""" resolver = ConstraintResolver() combo = Combination(entities=[road_vehicle, solar_sail, solar_radiation]) result = resolver.resolve(combo) assert result.status == "p1_fail" assert any("mutually exclusive" in v for v in result.violations) def test_spaceship_compatible_with_solar_sail(spaceship, solar_sail, solar_radiation): """Spaceship + Solar Sail both need space/vacuum — should not conflict.""" resolver = ConstraintResolver() combo = Combination(entities=[spaceship, solar_sail, solar_radiation]) result = resolver.resolve(combo) # Should not be blocked by atmosphere or medium medium_blocks = [v for v in result.violations if "mutually exclusive" in v] assert len(medium_blocks) == 0 def test_nuclear_drive_blocks_with_bicycle(bicycle, nuclear_thermal_drive, nuclear_fuel): """Nuclear drive min_mass=1500kg + fuel min_mass=500kg vs bicycle max_mass=30kg → range incompatibility.""" resolver = ConstraintResolver() combo = Combination(entities=[bicycle, nuclear_thermal_drive, nuclear_fuel]) result = resolver.resolve(combo) assert result.status == "p1_fail" assert any("mass" in v.lower() for v in result.violations) def test_requires_vs_excludes(): """Direct requires/excludes contradiction.""" a = Entity( name="A", dimension="platform", dependencies=[Dependency("environment", "oxygen", "true", None, "requires")], ) b = Entity( name="B", dimension="actuator", dependencies=[Dependency("environment", "oxygen", "true", None, "excludes")], ) resolver = ConstraintResolver() combo = Combination(entities=[a, b]) result = resolver.resolve(combo) assert result.status == "p1_fail" assert any("excludes" in v for v in result.violations) def test_ice_engine_blocks_with_spaceship(spaceship, ice_engine, gasoline): """ICE requires standard atmosphere, spaceship requires vacuum_or_thin → mutex.""" resolver = ConstraintResolver() combo = Combination(entities=[spaceship, ice_engine, gasoline]) result = resolver.resolve(combo) assert result.status == "p1_fail" assert any("atmosphere" in v for v in result.violations) def test_hydrogen_bicycle_valid(bicycle, hydrogen_engine, hydrogen): """Hydrogen bike — the README's example of a plausible novel concept.""" resolver = ConstraintResolver() combo = Combination(entities=[bicycle, hydrogen_engine, hydrogen]) result = resolver.resolve(combo) # Should pass constraints (mass range is compatible: h2 engine min 25kg, bike max 30kg) # This is actually a borderline case — let's just verify no hard physics blocks range_blocks = [v for v in result.violations if "mutually exclusive" in v or "atmosphere" in v] assert len(range_blocks) == 0 def test_energy_density_deficit_blocks(): """A platform needing 7200000 J/kg paired with a 720000 J/kg battery → blocked.""" platform = Entity( name="Spaceship", dimension="platform", dependencies=[ Dependency("physical", "energy_density", "7200000", "J/kg", "range_min"), ], ) storage = Entity( name="Battery", dimension="energy_storage", dependencies=[ Dependency("physical", "energy_density", "720000", "J/kg", "provides"), ], ) resolver = ConstraintResolver() combo = Combination(entities=[platform, storage]) result = resolver.resolve(combo) assert result.status == "p1_fail" assert any("deficit" in v for v in result.violations) def test_energy_density_under_density_warning(): """A platform needing 1440000 J/kg paired with a 720000 J/kg battery → conditional.""" platform = Entity( name="Airplane", dimension="platform", dependencies=[ Dependency("physical", "energy_density", "1440000", "J/kg", "range_min"), ], ) storage = Entity( name="Battery", dimension="energy_storage", dependencies=[ Dependency("physical", "energy_density", "720000", "J/kg", "provides"), ], ) resolver = ConstraintResolver() combo = Combination(entities=[platform, storage]) result = resolver.resolve(combo) assert result.status != "p1_fail" assert any("under-provision" in w for w in result.warnings) def test_energy_density_no_constraint_if_no_provider(): """A platform with energy density requirement but no declared provider → no violation.""" platform = Entity( name="Spaceship", dimension="platform", dependencies=[ Dependency("physical", "energy_density", "7200000", "J/kg", "range_min"), ], ) # Solar Sail-style: no energy_density declared actuator = Entity( name="Solar Sail", dimension="actuator", dependencies=[ Dependency("force", "power_density", "0.01", "W/kg", "provides"), ], ) resolver = ConstraintResolver() combo = Combination(entities=[platform, actuator]) result = resolver.resolve(combo) density_violations = [v for v in result.violations if "energy_density" in v] assert len(density_violations) == 0 def test_domain_constraint_blocks_wrong_medium(spaceship, solar_sail, solar_radiation): """Spaceship (space medium) should be blocked in a ground-only domain.""" resolver = ConstraintResolver() combo = Combination(entities=[spaceship, solar_sail, solar_radiation]) constraints = [DomainConstraint("medium", ["ground", "air"])] result = resolver.check_domain_constraints(combo, constraints) assert result.status == "p1_fail" assert any("medium" in v for v in result.violations) def test_domain_constraint_allows_matching_medium(bicycle, human_pedalling, food_calories): """Bicycle (ground medium) should pass a ground+air domain constraint.""" resolver = ConstraintResolver() combo = Combination(entities=[bicycle, human_pedalling, food_calories]) constraints = [DomainConstraint("medium", ["ground", "air"])] result = resolver.check_domain_constraints(combo, constraints) assert result.status == "valid" def _rotorcraft(): return Entity( name="Rotorcraft", dimension="platform", dependencies=[ Dependency("physical", "footprint", "20", "m²", "range_max"), Dependency("physical", "footprint", "0.5", "m²", "range_min"), Dependency("physical", "mass", "5000", "kg", "range_max"), Dependency("physical", "mass", "1", "kg", "range_min"), ], ) def _spaceship_with_footprint(): return Entity( name="Spaceship", dimension="platform", dependencies=[ Dependency("physical", "footprint", "500", "m²", "range_max"), Dependency("physical", "footprint", "10", "m²", "range_min"), Dependency("physical", "mass", "5000", "kg", "range_min"), ], ) def _nuclear_thermal_drive_with_footprint(): return Entity( name="Nuclear Thermal Drive", dimension="actuator", dependencies=[ Dependency("physical", "footprint", "20", "m²", "range_min"), Dependency("physical", "mass", "1500", "kg", "range_min"), ], ) def _nuclear_fuel_with_footprint(): return Entity( name="Nuclear Fuel", dimension="energy_storage", dependencies=[ Dependency("physical", "footprint", "5", "m²", "range_min"), Dependency("physical", "mass", "500", "kg", "range_min"), ], ) def test_footprint_aggregation_blocks_reactor_on_rotorcraft(): """P1: individual footprint floors each fit under the ceiling (20, 5 <= 20) but their sum (25.5) doesn't — must block even though no single component exceeds the ceiling on its own.""" resolver = ConstraintResolver() combo = Combination(entities=[ _rotorcraft(), _nuclear_thermal_drive_with_footprint(), _nuclear_fuel_with_footprint(), ]) result = resolver.resolve(combo) assert result.status == "p1_fail" assert any("combined footprint" in v for v in result.violations) def test_footprint_aggregation_still_passes_spaceship(): """Same reactor + fuel, but a platform with enough footprint budget (500 m²) must still pass — aggregation shouldn't over-block combos with real headroom.""" resolver = ConstraintResolver() combo = Combination(entities=[ _spaceship_with_footprint(), _nuclear_thermal_drive_with_footprint(), _nuclear_fuel_with_footprint(), ]) result = resolver.resolve(combo) assert result.status != "p1_fail" assert not any("footprint" in v for v in result.violations) def test_mass_aggregation_within_tolerance_warns_not_blocks(): """Sum only slightly over the ceiling (65 vs 60, +8.3%) is a data-calibration signal, not a hard physical impossibility — should warn, not block.""" platform = Entity( name="Light Personal Vehicle", dimension="platform", dependencies=[ Dependency("physical", "mass", "60", "kg", "range_max"), Dependency("physical", "mass", "5", "kg", "range_min"), ], ) actuator = Entity( name="Piston Engine", dimension="actuator", dependencies=[Dependency("physical", "mass", "45", "kg", "range_min")], ) storage = Entity( name="Compressed Natural Gas", dimension="energy_storage", dependencies=[Dependency("physical", "mass", "15", "kg", "range_min")], ) resolver = ConstraintResolver() result = resolver.resolve(Combination(entities=[platform, actuator, storage])) assert result.status == "conditional" assert any("combined mass" in w for w in result.warnings) def test_weak_secondary_provider_does_not_block_satisfied_requirement(): """P3: a strong provider (nuclear fuel) already satisfies the requirement; a weak secondary provider (solar panel) in the same combo must not retroactively block it — a real backup power source shouldn't break a vehicle that already has enough primary power.""" platform = Entity( name="Spaceship", dimension="platform", dependencies=[Dependency("physical", "energy_density", "7200000", "J/kg", "range_min")], ) nuclear_fuel = Entity( name="Nuclear Fuel", dimension="energy_storage", dependencies=[Dependency("physical", "energy_density", "1800000000", "J/kg", "provides")], ) solar_panel = Entity( name="Solar Photovoltaic Panel", dimension="energy_storage", dependencies=[Dependency("physical", "energy_density", "180000", "J/kg", "provides")], ) resolver = ConstraintResolver() result = resolver.resolve(Combination(entities=[platform, nuclear_fuel, solar_panel])) assert result.status != "p1_fail" assert not any("energy_density" in v for v in result.violations) def test_unrecognized_mutex_value_fails_closed(): """P4: a value not in any registered mutex set (e.g. a new 'medium' typed into the admin UI) must conflict with a recognized value on the same key, not silently pass.""" a = Entity( name="A", dimension="platform", dependencies=[Dependency("environment", "medium", "underground", None, "requires")], ) b = Entity( name="B", dimension="actuator", dependencies=[Dependency("environment", "medium", "space", None, "requires")], ) resolver = ConstraintResolver() result = resolver.resolve(Combination(entities=[a, b])) assert result.status == "p1_fail" assert any("mutually exclusive" in v for v in result.violations) def test_propulsion_viability_blocks_weak_actuator_regardless_of_scale(): """G4: specific_thrust below min_effective_accel can never be fixed by adding more actuator mass — must block unconditionally (Case 1).""" platform = Entity( name="Rotorcraft", dimension="platform", dependencies=[ Dependency("physical", "mass", "5000", "kg", "range_max"), Dependency("physical", "min_effective_accel", "10", "m/s²", "range_min"), ], ) actuator = Entity( name="Ion Drive", dimension="actuator", dependencies=[ Dependency("physical", "mass", "8", "kg", "range_min"), Dependency("force", "specific_thrust", "0.01", "N/kg", "provides"), ], ) resolver = ConstraintResolver() result = resolver.resolve(Combination(entities=[platform, actuator])) assert result.status == "p1_fail" assert any("regardless of scale" in v for v in result.violations) def test_propulsion_viability_blocks_when_required_mass_exceeds_ceiling(): """G4 Case 2: specific_thrust clears min_effective_accel, but the mass needed to hit that thrust doesn't fit the vehicle's mass budget.""" platform = Entity( name="Test Platform", dimension="platform", dependencies=[ Dependency("physical", "mass", "50", "kg", "range_max"), Dependency("physical", "mass", "10", "kg", "range_min"), Dependency("physical", "min_effective_accel", "5", "m/s²", "range_min"), ], ) actuator = Entity( name="Weak Reaction Drive", dimension="actuator", dependencies=[ Dependency("physical", "mass", "1", "kg", "range_min"), Dependency("force", "specific_thrust", "6", "N/kg", "provides"), ], ) storage = Entity( name="Fuel", dimension="energy_storage", dependencies=[Dependency("physical", "mass", "1", "kg", "range_min")], ) resolver = ConstraintResolver() result = resolver.resolve(Combination(entities=[platform, actuator, storage])) assert result.status == "p1_fail" assert any("would need >=" in v for v in result.violations) def test_propulsion_viability_passes_with_enough_budget(): """Same shape as above but with a generous mass ceiling — must pass.""" platform = Entity( name="Test Platform", dimension="platform", dependencies=[ Dependency("physical", "mass", "5000", "kg", "range_max"), Dependency("physical", "mass", "10", "kg", "range_min"), Dependency("physical", "min_effective_accel", "5", "m/s²", "range_min"), ], ) actuator = Entity( name="Weak Reaction Drive", dimension="actuator", dependencies=[ Dependency("physical", "mass", "1", "kg", "range_min"), Dependency("force", "specific_thrust", "6", "N/kg", "provides"), ], ) storage = Entity( name="Fuel", dimension="energy_storage", dependencies=[Dependency("physical", "mass", "1", "kg", "range_min")], ) resolver = ConstraintResolver() result = resolver.resolve(Combination(entities=[platform, actuator, storage])) assert not any("specific thrust" in v or "would need" in v for v in result.violations) def test_propulsion_viability_skips_when_undeclared(bicycle, human_pedalling, food_calories): """Platforms/actuators that never declare min_effective_accel or specific_thrust (most of the catalog, for now) must be unaffected.""" resolver = ConstraintResolver() result = resolver.resolve(Combination(entities=[bicycle, human_pedalling, food_calories])) assert not any("specific thrust" in v or "would need" in v for v in result.violations) def test_agreement_key_reaches_valid_status(bicycle, human_pedalling, food_calories): """P2: medium/atmosphere are agreement keys, not supply/demand — a combo with no other issues should reach 'valid', not get stuck at 'conditional' forever because nothing 'provides' medium=ground.""" resolver = ConstraintResolver() result = resolver.resolve(Combination(entities=[bicycle, human_pedalling, food_calories])) assert result.status == "valid" assert not any("medium" in w for w in result.warnings)