gl29/kervent-projections
0
1# tests/test_export_models.py2"""Test suite for model configuration export functionality.3 4Verifies that all model configurations (yurt, bnb, etc.) are properly5exported to JSON with complete structure validation.6"""7 8import json9import copy10 11import numpy as np12import pytest13 14from core import EntitySimulationResult, GlobalConfig15from export import (16 _flatten_inputs,17 generate_json_export,18 _numpy_to_python,19)20from ledger import (21 BaseLedgerConfig,22 CapExItem,23 CapExLedger,24 OpExItem,25 OpExLedger,26 RevenueModel,27 RevenueStream,28 TaxModel,29 TaxTables,30 Phase,31 SCIEntity,32 OpCoEntity,33 PersonEntity,34 DebtServiceModel,35)36 37 38# ==========================================39# FIXTURES40# ==========================================41 42 43@pytest.fixture44def mock_global_cfg():45 """Create a minimal global config."""46 return GlobalConfig(47 inflation_rate=0.02,48 vat_franchise_active=False,49 tax_tables=TaxTables.from_dict({}),50 )51 52 53@pytest.fixture54def mock_config():55 """Create a minimal KerventConfig-like object."""56 57 class MockKerventConfig:58 def __init__(self):59 self.capex = CapExLedger()60 self.opex = OpExLedger()61 62 def to_dict(self):63 return {"capex": self.capex.to_dict(), "opex": self.opex.to_dict()}64 65 return MockKerventConfig()66 67 68@pytest.fixture69def mock_sci():70 """Create a minimal SCIEntity."""71 return SCIEntity(72 monthly_rent=1000.0,73 rent_mode="fixed",74 dividend_reserve=5000.0,75 tax_regime="is",76 )77 78 79@pytest.fixture80def mock_opco():81 """Create a minimal OpCoEntity."""82 return OpCoEntity(dividend_reserve=5000.0)83 84 85@pytest.fixture86def mock_persons():87 """Create a list of minimal PersonEntity objects."""88 return [89 PersonEntity(90 name="Alice",91 min_monthly_salary=2000.0,92 target_monthly_salary=3000.0,93 external_monthly_income=0.0,94 ir_parts=1.0,95 ),96 ]97 98 99@pytest.fixture100def mock_phases():101 """Create a minimal phase list."""102 return [103 Phase(104 id="phase_1",105 name="Phase 1",106 trigger={"type": "immediate"},107 active_models=["yurt_model"],108 new_loan=DebtServiceModel(bank_pct=0.8, interest=0.04, loan_years=15),109 loan_entity="OpCo",110 model_configs={},111 )112 ]113 114 115@pytest.fixture116def mock_result():117 """Create a mock EntitySimulationResult."""118 max_t = 60119 opco_capex = np.zeros(max_t)120 opco_capex[0] = 10000121 122 return EntitySimulationResult(123 sci_treasury=np.linspace(100000, 200000, max_t),124 opco_treasury=np.linspace(50000, 150000, max_t),125 person_treasuries={"Alice": np.linspace(30000, 50000, max_t)},126 consolidated_treasury=np.linspace(180000, 350000, max_t),127 annual_sci_tax=5000.0,128 annual_opco_tax=3000.0,129 annual_person_tax={"Alice": 2000.0},130 opco_revenue=np.linspace(20000, 30000, max_t),131 opco_opex=np.linspace(5000, 6000, max_t),132 opco_social_charges=np.linspace(3000, 4000, max_t),133 sci_debt=np.linspace(100000, 50000, max_t),134 sci_interest=np.linspace(2000, 1000, max_t),135 sci_depreciation=np.ones(max_t) * 500,136 vat_net=np.linspace(1000, 2000, max_t),137 opco_capex=opco_capex,138 opco_loan_payments=np.linspace(2000, 2000, max_t),139 opco_pre_salary_treasury=np.linspace(5000, 15000, max_t),140 minimum_required_cash=150000.0,141 dscr=1.2,142 break_even_pct=50.0,143 break_even_idx=24,144 year_1_dividends=5000.0,145 phase_trigger_months=[24],146 )147 148 149@pytest.fixture150def yurt_model():151 """Create a yurt model config."""152 return {153 "name": "๐๏ธ Yourtes",154 "icon": "๐๏ธ",155 "default_active": True,156 "config": BaseLedgerConfig(157 revenue=RevenueModel(158 streams=[159 RevenueStream(160 name="Nightly rate",161 price=100.0,162 units=30,163 vat_rate=0.10,164 )165 ]166 ),167 capex=CapExLedger(168 items=[169 CapExItem(170 name="Yurt structure",171 amount=25000.0,172 start_delay=0,173 count=1,174 useful_life_years=20,175 vat_rate=0.20,176 )177 ]178 ),179 opex=OpExLedger(180 items=[181 OpExItem(182 name="Cleaning",183 monthly_amount=500.0,184 inflates=True,185 )186 ]187 ),188 tax=TaxModel(),189 ),190 }191 192 193@pytest.fixture194def bnb_model():195 """Create a B&B model config."""196 return {197 "name": "๐๏ธ Chambre d'Hรดtes",198 "icon": "๐๏ธ",199 "default_active": False,200 "config": BaseLedgerConfig(201 revenue=RevenueModel(202 streams=[203 RevenueStream(204 name="Room rental",205 price=80.0,206 units=20,207 vat_rate=0.10,208 ),209 RevenueStream(210 name="Breakfast",211 price=15.0,212 units=20,213 vat_rate=0.10,214 ),215 ]216 ),217 capex=CapExLedger(items=[]),218 opex=OpExLedger(219 items=[220 OpExItem(221 name="Maintenance",222 monthly_amount=300.0,223 inflates=True,224 )225 ]226 ),227 tax=TaxModel(),228 ),229 }230 231 232@pytest.fixture233def multi_model_dict(yurt_model, bnb_model):234 """Create a multi-model dictionary (simulates st.session_state.models)."""235 return {236 "yurt_model": yurt_model,237 "bnb_model": bnb_model,238 }239 240 241# ==========================================242# STRUCTURE VALIDATION TESTS243# ==========================================244 245 246def test_flatten_inputs_with_no_models(247 mock_sci, mock_opco, mock_persons, mock_phases, mock_global_cfg248):249 """Test that models_library is empty when no models provided."""250 inputs = _flatten_inputs(251 mock_sci,252 mock_opco,253 mock_persons,254 mock_phases,255 mock_global_cfg,256 models=None,257 )258 259 assert "models_library" in inputs260 assert inputs["models_library"] == {}261 262 263def test_flatten_inputs_with_empty_models_dict(264 mock_sci, mock_opco, mock_persons, mock_phases, mock_global_cfg265):266 """Test that models_library is empty when empty dict provided."""267 inputs = _flatten_inputs(268 mock_sci,269 mock_opco,270 mock_persons,271 mock_phases,272 mock_global_cfg,273 models={},274 )275 276 assert "models_library" in inputs277 assert inputs["models_library"] == {}278 279 280def test_flatten_inputs_with_single_model(281 mock_sci,282 mock_opco,283 mock_persons,284 mock_phases,285 mock_global_cfg,286 yurt_model,287):288 """Test that single model is properly exported."""289 models = {"yurt_model": yurt_model}290 inputs = _flatten_inputs(291 mock_sci,292 mock_opco,293 mock_persons,294 mock_phases,295 mock_global_cfg,296 models=models,297 )298 299 assert "models_library" in inputs300 assert "yurt_model" in inputs["models_library"]301 302 exported_model = inputs["models_library"]["yurt_model"]303 assert exported_model["name"] == "๐๏ธ Yourtes"304 assert exported_model["icon"] == "๐๏ธ"305 assert exported_model["default_active"] is True306 assert "config" in exported_model307 308 309def test_flatten_inputs_with_multiple_models(310 mock_sci,311 mock_opco,312 mock_persons,313 mock_phases,314 mock_global_cfg,315 multi_model_dict,316):317 """Test that all models are properly exported."""318 inputs = _flatten_inputs(319 mock_sci,320 mock_opco,321 mock_persons,322 mock_phases,323 mock_global_cfg,324 models=multi_model_dict,325 )326 327 assert "models_library" in inputs328 assert len(inputs["models_library"]) == 2329 assert "yurt_model" in inputs["models_library"]330 assert "bnb_model" in inputs["models_library"]331 332 333def test_model_library_structure_single_model(334 mock_sci,335 mock_opco,336 mock_persons,337 mock_phases,338 mock_global_cfg,339 yurt_model,340):341 """Test that model structure contains all required fields."""342 models = {"yurt_model": yurt_model}343 inputs = _flatten_inputs(344 mock_sci,345 mock_opco,346 mock_persons,347 mock_phases,348 mock_global_cfg,349 models=models,350 )351 352 model_lib = inputs["models_library"]353 assert "yurt_model" in model_lib354 355 model = model_lib["yurt_model"]356 # Required fields357 assert "name" in model358 assert "icon" in model359 assert "default_active" in model360 assert "config" in model361 362 # Verify types363 assert isinstance(model["name"], str)364 assert isinstance(model["icon"], str)365 assert isinstance(model["default_active"], bool)366 assert isinstance(model["config"], dict)367 368 369def test_model_config_structure(370 mock_sci,371 mock_opco,372 mock_persons,373 mock_phases,374 mock_global_cfg,375 yurt_model,376):377 """Test that model config has all revenue/capex/opex/tax."""378 models = {"yurt_model": yurt_model}379 inputs = _flatten_inputs(380 mock_sci,381 mock_opco,382 mock_persons,383 mock_phases,384 mock_global_cfg,385 models=models,386 )387 388 config = inputs["models_library"]["yurt_model"]["config"]389 390 # All config sections must be present391 assert "revenue" in config392 assert "capex" in config393 assert "opex" in config394 assert "tax" in config395 396 # All should be dicts397 assert isinstance(config["revenue"], dict)398 assert isinstance(config["capex"], dict)399 assert isinstance(config["opex"], dict)400 assert isinstance(config["tax"], dict)401 402 403def test_model_config_revenue_structure(404 mock_sci,405 mock_opco,406 mock_persons,407 mock_phases,408 mock_global_cfg,409 yurt_model,410):411 """Test that revenue streams are properly serialized."""412 models = {"yurt_model": yurt_model}413 inputs = _flatten_inputs(414 mock_sci,415 mock_opco,416 mock_persons,417 mock_phases,418 mock_global_cfg,419 models=models,420 )421 422 revenue = inputs["models_library"]["yurt_model"]["config"]["revenue"]423 424 # Should have streams425 assert "streams" in revenue426 assert isinstance(revenue["streams"], list)427 assert len(revenue["streams"]) > 0428 429 # Each stream should have required fields430 stream = revenue["streams"][0]431 assert "name" in stream432 assert "price" in stream433 assert "units" in stream434 435 436def test_model_config_capex_structure(437 mock_sci,438 mock_opco,439 mock_persons,440 mock_phases,441 mock_global_cfg,442 yurt_model,443):444 """Test that CapEx items are properly serialized."""445 models = {"yurt_model": yurt_model}446 inputs = _flatten_inputs(447 mock_sci,448 mock_opco,449 mock_persons,450 mock_phases,451 mock_global_cfg,452 models=models,453 )454 455 capex = inputs["models_library"]["yurt_model"]["config"]["capex"]456 457 # Should have items list458 assert "items" in capex459 assert isinstance(capex["items"], list)460 assert len(capex["items"]) > 0461 462 # Each item should have required fields463 item = capex["items"][0]464 assert "name" in item465 assert "amount" in item466 467 468def test_model_config_opex_structure(469 mock_sci,470 mock_opco,471 mock_persons,472 mock_phases,473 mock_global_cfg,474 yurt_model,475):476 """Test that OpEx items are properly serialized."""477 models = {"yurt_model": yurt_model}478 inputs = _flatten_inputs(479 mock_sci,480 mock_opco,481 mock_persons,482 mock_phases,483 mock_global_cfg,484 models=models,485 )486 487 opex = inputs["models_library"]["yurt_model"]["config"]["opex"]488 489 # Should have items list490 assert "items" in opex491 assert isinstance(opex["items"], list)492 assert len(opex["items"]) > 0493 494 # Each item should have required fields495 item = opex["items"][0]496 assert "name" in item497 assert "monthly_amount" in item498 499 500def test_all_models_exported(501 mock_sci,502 mock_opco,503 mock_persons,504 mock_phases,505 mock_global_cfg,506 multi_model_dict,507):508 """Test that every model in the dict is exported."""509 inputs = _flatten_inputs(510 mock_sci,511 mock_opco,512 mock_persons,513 mock_phases,514 mock_global_cfg,515 models=multi_model_dict,516 )517 518 # Every key in multi_model_dict should be in models_library519 for model_id in multi_model_dict.keys():520 assert model_id in inputs["models_library"]521 522 523def test_generate_json_export_with_models(524 mock_sci,525 mock_opco,526 mock_persons,527 mock_phases,528 mock_result,529 mock_global_cfg,530 multi_model_dict,531):532 """Test that JSON export includes models_library."""533 json_content = generate_json_export(534 mock_sci,535 mock_opco,536 mock_persons,537 mock_phases,538 mock_result,539 mock_global_cfg,540 include_inputs=True,541 include_outputs=True,542 scenario_name="Test with Models",543 models=multi_model_dict,544 )545 546 assert "metadata" in json_content547 assert "parameters" in json_content548 assert "monthly_data" in json_content549 assert "annual_summary" in json_content550 551 # Models should be in parameters552 params = json_content["parameters"]553 assert "models_library" in params554 assert len(params["models_library"]) == 2555 556 557def test_json_export_is_serializable(558 mock_sci,559 mock_opco,560 mock_persons,561 mock_phases,562 mock_result,563 mock_global_cfg,564 multi_model_dict,565):566 """Test that JSON export can be converted to JSON string."""567 json_content = generate_json_export(568 mock_sci,569 mock_opco,570 mock_persons,571 mock_phases,572 mock_result,573 mock_global_cfg,574 include_inputs=True,575 include_outputs=True,576 models=multi_model_dict,577 )578 579 # Should be JSON-serializable580 json_str = json.dumps(json_content, indent=2, default=str)581 assert isinstance(json_str, str)582 assert len(json_str) > 0583 584 # Should parse back585 reparsed = json.loads(json_str)586 assert "parameters" in reparsed587 assert "models_library" in reparsed["parameters"]588 589 590def test_model_metadata_preserved(591 mock_sci,592 mock_opco,593 mock_persons,594 mock_phases,595 mock_global_cfg,596 yurt_model,597 bnb_model,598):599 """Test that model name/icon/default_active are preserved exactly."""600 models = {"yurt_model": yurt_model, "bnb_model": bnb_model}601 inputs = _flatten_inputs(602 mock_sci,603 mock_opco,604 mock_persons,605 mock_phases,606 mock_global_cfg,607 models=models,608 )609 610 # Verify yurt metadata611 yurt_exported = inputs["models_library"]["yurt_model"]612 assert yurt_exported["name"] == yurt_model["name"]613 assert yurt_exported["icon"] == yurt_model["icon"]614 assert yurt_exported["default_active"] == yurt_model["default_active"]615 616 # Verify bnb metadata617 bnb_exported = inputs["models_library"]["bnb_model"]618 assert bnb_exported["name"] == bnb_model["name"]619 assert bnb_exported["icon"] == bnb_model["icon"]620 assert bnb_exported["default_active"] == bnb_model["default_active"]621 622 623def test_numpy_conversion_in_models(624 mock_sci,625 mock_opco,626 mock_persons,627 mock_phases,628 mock_global_cfg,629 yurt_model,630):631 """Test that NumPy types in model configs are converted to Python types."""632 # Manually inject numpy values to test conversion633 test_model = copy.deepcopy(yurt_model)634 # The BaseLedgerConfig already converts through to_dict, so this verifies635 # the _numpy_to_python function works on the converted output636 637 models = {"yurt_model": test_model}638 inputs = _flatten_inputs(639 mock_sci,640 mock_opco,641 mock_persons,642 mock_phases,643 mock_global_cfg,644 models=models,645 )646 647 # Verify all numeric values are Python types, not NumPy648 model_lib = inputs["models_library"]["yurt_model"]649 json_str = json.dumps(model_lib, default=str)650 # Should not raise an error651 assert isinstance(json_str, str)652 653 654if __name__ == "__main__":655 pytest.main([__file__, "-v"])656 