From 443818bacd3751c3007380fb688ee26f124d9c5e Mon Sep 17 00:00:00 2001 From: Priyanka Punukollu Date: Thu, 26 Feb 2026 21:36:20 -0600 Subject: [PATCH] =?UTF-8?q?test:=20expand=20eval=20dataset=20to=2056=20new?= =?UTF-8?q?=20cases=20=E2=80=94=2020=20happy=20path,=2012=20edge,=2012=20a?= =?UTF-8?q?dversarial,=2012=20multi-step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created agent/evals/test_eval_dataset.py with 56 categorized test cases - Happy path (20): portfolio, property CRUD, strategy, wealth position, family, relocation - Edge cases (12): zero values, paid-off property, 1-year strategy, nonexistent IDs, extreme ages - Adversarial (12): SQL injection, negative values, extreme rates, whitespace inputs - Multi-step (12): chained tool calls, stateful CRUD flows, cross-tool data passing - Total suite: 182 tests, 0 failures Made-with: Cursor --- agent/evals/test_eval_dataset.py | 941 +++++++++++++++++++++++++++++++ 1 file changed, 941 insertions(+) create mode 100644 agent/evals/test_eval_dataset.py diff --git a/agent/evals/test_eval_dataset.py b/agent/evals/test_eval_dataset.py new file mode 100644 index 000000000..3225c1abc --- /dev/null +++ b/agent/evals/test_eval_dataset.py @@ -0,0 +1,941 @@ +""" +Comprehensive eval dataset for the Ghostfolio AI Agent. + +50+ test cases organized into 4 rubric-required categories: + - Happy Path (20+ tests): normal successful user journeys + - Edge Cases (10+ tests): boundary conditions, missing data, zero values + - Adversarial (10+ tests): bad inputs, injection attempts, extreme values + - Multi-Step (10+ tests): chained tool calls, stateful flows + +Every test documents: + TYPE, INPUT (what the user asked), EXPECTED (what should happen), + CRITERIA (how pass/fail is determined). + +Network calls: all Teleport API calls are mocked via conftest.py autouse +fixture (mock_teleport_no_network). Tests are deterministic and fast. +""" + +import asyncio +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools")) + +os.environ.setdefault("ENABLE_REAL_ESTATE", "true") +os.environ.setdefault("PROPERTIES_DB_PATH", ":memory:") + +import pytest + + +# --------------------------------------------------------------------------- +# Helper: run coroutine from sync context +# --------------------------------------------------------------------------- + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _clear(): + from property_tracker import property_store_clear + property_store_clear() + + +# ============================================================================ +# HAPPY PATH TESTS (20 tests) +# ============================================================================ + +# TYPE: happy_path +# INPUT: add home with purchase price, current value, and mortgage +# EXPECTED: property created, equity = current_value - mortgage_balance +# CRITERIA: equity == 175000, success == True +def test_hp_add_property_basic(): + _clear() + from property_tracker import add_property + result = _run(add_property( + address="My Primary Home", + purchase_price=420000, + current_value=490000, + mortgage_balance=315000, + )) + assert result["success"] is True + prop = result["result"]["property"] + assert prop["equity"] == pytest.approx(175000) + + +# TYPE: happy_path +# INPUT: add two properties, check combined equity +# EXPECTED: total equity = sum of both properties' equity +# CRITERIA: total_equity matches manual calculation +def test_hp_add_two_properties_combined_equity(): + _clear() + from property_tracker import add_property, get_real_estate_equity + _run(add_property("Home A", 400000, 480000, 300000)) # equity=180000 + _run(add_property("Home B", 300000, 350000, 200000)) # equity=150000 + result = _run(get_real_estate_equity()) + assert result["success"] is True + assert result["result"]["total_real_estate_equity"] == pytest.approx(330000) + assert result["result"]["property_count"] == 2 + + +# TYPE: happy_path +# INPUT: get total net worth with portfolio and property +# EXPECTED: total = portfolio + real estate equity +# CRITERIA: total_net_worth == 94000 + 175000 = 269000 +def test_hp_total_net_worth_combined(): + _clear() + from property_tracker import add_property, get_total_net_worth + _run(add_property("Test Home", 420000, 490000, 315000)) # equity=175000 + result = _run(get_total_net_worth(portfolio_value=94000)) + assert result["success"] is True + assert result["result"]["total_net_worth"] == pytest.approx(269000) + assert result["result"]["investment_portfolio"] == 94000 + assert result["result"]["real_estate_equity"] == pytest.approx(175000) + + +# TYPE: happy_path +# INPUT: strategy simulation with 10-year horizon +# EXPECTED: final net worth exceeds starting portfolio +# CRITERIA: total_net_worth > initial_portfolio_value +def test_hp_strategy_10_year_growth(): + from realestate_strategy import simulate_real_estate_strategy + result = simulate_real_estate_strategy(94000, 120000, 400000, total_years=10) + assert result is not None + assert "final_picture" in result + assert result["final_picture"]["total_net_worth"] > 94000 + + +# TYPE: happy_path +# INPUT: strategy simulation with user-provided 3% appreciation +# EXPECTED: strategy uses 3% not default 4% +# CRITERIA: assumptions show 3.0% +def test_hp_strategy_user_appreciation(): + from realestate_strategy import simulate_real_estate_strategy + result = simulate_real_estate_strategy( + 94000, 120000, 400000, + annual_appreciation=0.03, + ) + assert result["strategy"]["assumptions"]["annual_appreciation"] == "3.0%" + + +# TYPE: happy_path +# INPUT: analyze wealth position for 34-year-old with $94k portfolio +# EXPECTED: Fed Reserve comparison with correct median for under_35 bracket +# CRITERIA: median_for_age == 39000, percentile_estimate present +def test_hp_wealth_position_age_34(): + from wealth_visualizer import analyze_wealth_position + result = analyze_wealth_position(94000, 34, 120000) + assert "current_position" in result + assert result["current_position"]["median_for_age"] == 39000 + assert "percentile_estimate" in result["current_position"] + assert "retirement_projection" in result + + +# TYPE: happy_path +# INPUT: analyze wealth position for 42-year-old +# EXPECTED: uses 35_to_44 bracket median of $135,000 +# CRITERIA: median_for_age == 135000 +def test_hp_wealth_position_age_42(): + from wealth_visualizer import analyze_wealth_position + result = analyze_wealth_position(200000, 42, 150000) + assert result["current_position"]["median_for_age"] == 135000 + + +# TYPE: happy_path +# INPUT: equity options for property with substantial equity +# EXPECTED: 3 distinct options returned (keep, refi, rental) +# CRITERIA: len(options) >= 3, each option has projection +def test_hp_equity_options_three_scenarios(): + _clear() + from property_tracker import add_property, analyze_equity_options + prop = _run(add_property("Equity Home", 400000, 520000, 370000)) + pid = prop["result"]["property"]["id"] + result = analyze_equity_options(pid) + assert "options" in result + assert len(result["options"]) >= 3 + + +# TYPE: happy_path +# INPUT: family planning for Austin with 1 child +# EXPECTED: childcare costs, monthly surplus, income_impact +# CRITERIA: income_impact present, monthly_surplus_after is a number +def test_hp_family_plan_one_child_austin(): + from family_planner import plan_family_finances + result = plan_family_finances("Austin", 120000, num_planned_children=1) + assert "income_impact" in result + assert "monthly_surplus_after" in result["income_impact"] + assert isinstance(result["income_impact"]["monthly_surplus_after"], (int, float)) + + +# TYPE: happy_path +# INPUT: relocation runway calculation Austin → Seattle +# EXPECTED: verdict returned, months to emergency fund calculated +# CRITERIA: verdict present, milestones_if_you_move has emergency_fund milestone +def test_hp_relocation_runway_seattle(): + from relocation_runway import calculate_relocation_runway + result = calculate_relocation_runway( + current_salary=120000, + offer_salary=180000, + current_city="Austin", + destination_city="Seattle", + portfolio_value=94000, + ) + assert "verdict" in result + assert "milestones_if_you_move" in result + assert "months_to_6mo_emergency_fund" in result["milestones_if_you_move"] + + +# TYPE: happy_path +# INPUT: job offer affordability check Austin → SF +# EXPECTED: COL-adjusted comparison, is_real_raise boolean +# CRITERIA: is_real_raise present, verdict non-empty +@pytest.mark.asyncio +async def test_hp_job_offer_affordability(): + from wealth_bridge import calculate_job_offer_affordability + result = await calculate_job_offer_affordability( + offer_salary=180000, + offer_city="Seattle", + current_salary=120000, + current_city="Austin", + ) + assert "is_real_raise" in result + assert isinstance(result["is_real_raise"], bool) + assert "verdict" in result + assert len(result["verdict"]) > 10 + + +# TYPE: happy_path +# INPUT: down payment power for $94k portfolio +# EXPECTED: at least one Austin-area market affordable at full 20% down +# CRITERIA: can_afford_full True for at least one market +def test_hp_down_payment_94k_portfolio(): + from wealth_bridge import calculate_down_payment_power + result = calculate_down_payment_power(94000) + assert "markets" in result + affordable = [m for m in result["markets"] if m["can_afford_full"]] + assert len(affordable) > 0 + + +# TYPE: happy_path +# INPUT: list properties when one exists +# EXPECTED: property appears in list with correct fields +# CRITERIA: len(properties) == 1, equity field present +def test_hp_list_properties_one(): + _clear() + from property_tracker import add_property, get_properties + _run(add_property("My Home", 400000, 480000, 320000)) + result = _run(get_properties()) + assert result["success"] is True + props = result["result"]["properties"] + assert len(props) == 1 + assert "equity" in props[0] + assert props[0]["equity"] == pytest.approx(160000) + + +# TYPE: happy_path +# INPUT: remove property by ID +# EXPECTED: property no longer in list +# CRITERIA: property count drops from 1 to 0 +def test_hp_remove_property_success(): + _clear() + from property_tracker import add_property, remove_property, get_properties + prop = _run(add_property("Remove Me", 300000, 350000, 200000)) + pid = prop["result"]["property"]["id"] + removed = _run(remove_property(pid)) + assert removed["success"] is True + listed = _run(get_properties()) + ids = [p["id"] for p in listed["result"]["properties"]] + assert pid not in ids + + +# TYPE: happy_path +# INPUT: update property current value +# EXPECTED: equity recalculates correctly +# CRITERIA: equity increases after value update +def test_hp_update_property_value(): + _clear() + from property_tracker import add_property, update_property + prop = _run(add_property("Update Test", 400000, 450000, 320000)) + pid = prop["result"]["property"]["id"] + updated = _run(update_property(pid, current_value=470000)) + assert updated["success"] is True + new_equity = updated["result"]["property"]["equity"] + assert new_equity == pytest.approx(150000) # 470000 - 320000 + + +# TYPE: happy_path +# INPUT: strategy simulation single property scenario +# EXPECTED: result contains at least 1 property +# CRITERIA: properties_owned >= 1 +def test_hp_strategy_single_property(): + from realestate_strategy import simulate_real_estate_strategy + result = simulate_real_estate_strategy( + 94000, 120000, 400000, + buy_interval_years=10, # only 1 purchase in 10 years + total_years=10, + ) + assert result["final_picture"]["num_properties_owned"] >= 1 + + +# TYPE: happy_path +# INPUT: family plan with partner income +# EXPECTED: household income reflects both incomes +# CRITERIA: total_household_income > single income +def test_hp_family_plan_with_partner(): + from family_planner import plan_family_finances + result = plan_family_finances("Austin", 120000, partner_income=80000, num_planned_children=1) + assert "income_impact" in result + data = result["income_impact"] + assert "total_household_income" in data or "monthly_surplus_after" in data + + +# TYPE: happy_path +# INPUT: wealth position with real estate equity added +# EXPECTED: total net worth includes real estate +# CRITERIA: total_net_worth > portfolio_value alone +def test_hp_wealth_position_with_real_estate(): + from wealth_visualizer import analyze_wealth_position + result = analyze_wealth_position( + portfolio_value=94000, + age=34, + annual_income=120000, + real_estate_equity=175000, + ) + assert "current_position" in result + pos = result["current_position"] + assert pos["total_net_worth"] == pytest.approx(269000) + + +# TYPE: happy_path +# INPUT: relocation to cheaper city (SF → Austin) +# EXPECTED: destination surplus higher than current, verdict positive +# CRITERIA: destination monthly_surplus > 0, verdict present +def test_hp_relocation_to_cheaper_city(): + from relocation_runway import calculate_relocation_runway + result = calculate_relocation_runway( + current_salary=150000, + offer_salary=140000, + current_city="San Francisco", + destination_city="Austin", + portfolio_value=100000, + ) + assert "verdict" in result + dest_surplus = result["destination_monthly"]["monthly_surplus"] + curr_surplus = result["current_monthly"]["monthly_surplus"] + assert dest_surplus > curr_surplus + + +# TYPE: happy_path +# INPUT: strategy with conservative appreciation preset +# EXPECTED: conservative < optimistic final net worth +# CRITERIA: conservative_net_worth < optimistic_net_worth +def test_hp_strategy_conservative_vs_optimistic(): + from realestate_strategy import simulate_real_estate_strategy + conservative = simulate_real_estate_strategy( + 94000, 120000, 400000, annual_appreciation=0.02, + ) + optimistic = simulate_real_estate_strategy( + 94000, 120000, 400000, annual_appreciation=0.06, + ) + assert (conservative["final_picture"]["total_net_worth"] < + optimistic["final_picture"]["total_net_worth"]) + + +# ============================================================================ +# EDGE CASE TESTS (12 tests) +# ============================================================================ + +# TYPE: edge_case +# INPUT: portfolio value of zero +# EXPECTED: graceful response, not a crash +# CRITERIA: returns dict, no exception +def test_ec_zero_portfolio_value(): + from wealth_visualizer import analyze_wealth_position + result = analyze_wealth_position(0, 30, 50000) + assert result is not None + assert isinstance(result, dict) + assert "current_position" in result + + +# TYPE: edge_case +# INPUT: property with no mortgage (fully paid off) +# EXPECTED: equity equals current value +# CRITERIA: equity == current_value, equity_pct == 100.0 +def test_ec_paid_off_property(): + _clear() + from property_tracker import add_property + result = _run(add_property( + address="Paid Off Home", + purchase_price=300000, + current_value=380000, + mortgage_balance=0, + )) + prop = result["result"]["property"] + assert prop["equity"] == pytest.approx(380000) + assert prop["equity_pct"] == pytest.approx(100.0) + + +# TYPE: edge_case +# INPUT: strategy simulation for just 1 year +# EXPECTED: returns valid result, no crash +# CRITERIA: result has final_picture and timeline, no exception +def test_ec_strategy_single_year(): + from realestate_strategy import simulate_real_estate_strategy + result = simulate_real_estate_strategy(50000, 80000, 300000, total_years=1) + assert result is not None + assert "final_picture" in result + assert "timeline" in result + assert isinstance(result["final_picture"]["num_properties_owned"], int) + + +# TYPE: edge_case +# INPUT: equity options on nonexistent property ID +# EXPECTED: error dict returned, not exception +# CRITERIA: "error" key present in result +def test_ec_equity_nonexistent_property(): + from property_tracker import analyze_equity_options + result = analyze_equity_options("does-not-exist-999") + assert result is not None + assert "error" in result + + +# TYPE: edge_case +# INPUT: family planner with zero income +# EXPECTED: graceful response, no ZeroDivisionError +# CRITERIA: returns dict, no exception raised +def test_ec_family_planner_zero_income(): + from family_planner import plan_family_finances + result = plan_family_finances("Austin", 0, num_planned_children=1) + assert result is not None + assert isinstance(result, dict) + + +# TYPE: edge_case +# INPUT: wealth position for someone at exactly the median +# EXPECTED: correctly identified as median tier +# CRITERIA: percentile_estimate contains "50th" or similar +def test_ec_wealth_exactly_at_median(): + from wealth_visualizer import analyze_wealth_position + # median for under_35 is 39000 + result = analyze_wealth_position(39000, 30, 60000) + pos = result["current_position"] + assert "50th" in pos["percentile_estimate"] or "median" in pos["percentile_estimate"].lower() + + +# TYPE: edge_case +# INPUT: empty property list — get net worth with no properties +# EXPECTED: net worth equals portfolio value, real estate equity is 0 +# CRITERIA: total_net_worth == portfolio_value, real_estate_equity == 0 +def test_ec_net_worth_no_properties(): + _clear() + from property_tracker import get_total_net_worth + result = _run(get_total_net_worth(portfolio_value=50000)) + assert result["success"] is True + assert result["result"]["total_net_worth"] == pytest.approx(50000) + assert result["result"]["real_estate_equity"] == 0 + + +# TYPE: edge_case +# INPUT: relocation with identical source and destination city +# EXPECTED: no crash, verdict makes sense +# CRITERIA: returns dict with verdict field +def test_ec_same_city_relocation(): + from relocation_runway import calculate_relocation_runway + result = calculate_relocation_runway( + current_salary=120000, + offer_salary=125000, + current_city="Austin", + destination_city="Austin", + portfolio_value=94000, + ) + assert result is not None + assert "verdict" in result + + +# TYPE: edge_case +# INPUT: strategy with very large portfolio (1M) +# EXPECTED: simulation completes without overflow +# CRITERIA: total_net_worth is a positive finite number +def test_ec_strategy_large_portfolio(): + from realestate_strategy import simulate_real_estate_strategy + result = simulate_real_estate_strategy( + initial_portfolio_value=1_000_000, + annual_income=300000, + first_home_price=1_500_000, + total_years=10, + ) + assert result is not None + nw = result["final_picture"]["total_net_worth"] + assert nw > 0 + assert nw < 1e15 # sanity check: not overflowing + + +# TYPE: edge_case +# INPUT: analyze property with equity exceeding current value (impossible) +# EXPECTED: graceful handling, equity capped or error message +# CRITERIA: no crash, returns dict +def test_ec_mortgage_exceeds_value(): + _clear() + from property_tracker import add_property + result = _run(add_property( + address="Underwater Property", + purchase_price=400000, + current_value=300000, + mortgage_balance=380000, + )) + # Should succeed but equity will be negative (underwater) + assert result["success"] is True + prop = result["result"]["property"] + equity = prop["equity"] + # underwater: equity = 300000 - 380000 = -80000 (valid, just negative) + assert isinstance(equity, (int, float)) + + +# TYPE: edge_case +# INPUT: family plan for 5 children +# EXPECTED: costs scaled proportionally, no crash +# CRITERIA: childcare costs for 5 > childcare for 1 +def test_ec_family_many_children(): + from family_planner import plan_family_finances + result1 = plan_family_finances("Austin", 200000, num_planned_children=1) + result5 = plan_family_finances("Austin", 200000, num_planned_children=5) + assert result1 is not None and result5 is not None + # Costs for 5 children should be higher + s1 = result1["income_impact"]["monthly_surplus_after"] + s5 = result5["income_impact"]["monthly_surplus_after"] + assert s5 < s1 # more children = lower surplus + + +# TYPE: edge_case +# INPUT: wealth position for very old age (80+) +# EXPECTED: uses highest bracket (65+), no crash +# CRITERIA: median_for_age == 409000 (65_to_74 bracket) +def test_ec_wealth_very_old_age(): + from wealth_visualizer import analyze_wealth_position + result = analyze_wealth_position(500000, 82, 60000) + assert result is not None + assert result["current_position"]["median_for_age"] == 409000 + + +# ============================================================================ +# ADVERSARIAL TESTS (12 tests) +# ============================================================================ + +# TYPE: adversarial +# INPUT: property address contains SQL injection +# EXPECTED: stored safely as a string, DB still works after +# CRITERIA: no exception, subsequent queries work normally +def test_adv_sql_injection_address(): + _clear() + from property_tracker import add_property, get_properties + malicious = "'; DROP TABLE properties; --" + result = _run(add_property( + address=malicious, + purchase_price=300000, + current_value=350000, + mortgage_balance=200000, + )) + assert result["success"] is True + # DB still works after the injection attempt + props = _run(get_properties()) + assert props["success"] is True + + +# TYPE: adversarial +# INPUT: negative property values +# EXPECTED: validation catches it OR handles gracefully +# CRITERIA: no uncaught exception +def test_adv_negative_property_value(): + _clear() + from property_tracker import add_property + try: + result = _run(add_property( + address="Bad Property", + purchase_price=-100000, + current_value=-50000, + mortgage_balance=0, + )) + # If it accepts, result should be a dict + assert result is not None + except (ValueError, AssertionError): + pass # Rejecting invalid input is correct behavior + + +# TYPE: adversarial +# INPUT: impossible appreciation rate (1000% = 10.0) +# EXPECTED: simulation runs without crash +# CRITERIA: returns result dict with final_picture +def test_adv_extreme_appreciation_rate(): + from realestate_strategy import simulate_real_estate_strategy + result = simulate_real_estate_strategy( + 94000, 120000, 400000, + annual_appreciation=10.0, # 1000% — extreme input + ) + assert result is not None + assert "final_picture" in result + + +# TYPE: adversarial +# INPUT: strategy with zero annual income +# EXPECTED: graceful, not ZeroDivision +# CRITERIA: returns dict, no crash +def test_adv_strategy_zero_income(): + from realestate_strategy import simulate_real_estate_strategy + try: + result = simulate_real_estate_strategy( + initial_portfolio_value=100000, + annual_income=0, + first_home_price=300000, + total_years=5, + ) + assert result is not None + assert isinstance(result, dict) + except ZeroDivisionError: + pytest.fail("ZeroDivisionError raised with zero income") + + +# TYPE: adversarial +# INPUT: mortgage rate of 100% (extreme) +# EXPECTED: simulation completes, not crash +# CRITERIA: result is a dict with final_picture +def test_adv_extreme_mortgage_rate(): + from realestate_strategy import simulate_real_estate_strategy + result = simulate_real_estate_strategy( + 94000, 120000, 400000, + mortgage_rate=1.00, # 100% rate + total_years=5, + ) + assert result is not None + assert "final_picture" in result + + +# TYPE: adversarial +# INPUT: address that is only whitespace +# EXPECTED: validation returns structured error +# CRITERIA: success=False, error dict present +def test_adv_whitespace_address(): + _clear() + from property_tracker import add_property + result = _run(add_property(address=" \t\n ", purchase_price=300000)) + assert result["success"] is False + assert "error" in result + + +# TYPE: adversarial +# INPUT: purchase price is zero +# EXPECTED: validation error returned +# CRITERIA: success=False +def test_adv_zero_purchase_price(): + _clear() + from property_tracker import add_property + result = _run(add_property(address="Test", purchase_price=0)) + assert result["success"] is False + + +# TYPE: adversarial +# INPUT: wealth analysis with negative portfolio +# EXPECTED: graceful handling, no crash +# CRITERIA: returns dict +def test_adv_negative_portfolio_wealth(): + from wealth_visualizer import analyze_wealth_position + try: + result = analyze_wealth_position(-5000, 30, 60000) + assert result is not None + assert isinstance(result, dict) + except Exception: + pass # Rejecting negative portfolio is acceptable + + +# TYPE: adversarial +# INPUT: family plan for a city not in the lookup +# EXPECTED: uses default costs, no crash +# CRITERIA: returns dict with income_impact +def test_adv_unknown_city_family_plan(): + from family_planner import plan_family_finances + result = plan_family_finances("Xanadu City", 80000, num_planned_children=1) + assert result is not None + assert "income_impact" in result + + +# TYPE: adversarial +# INPUT: remove a nonexistent property ID +# EXPECTED: structured error dict returned +# CRITERIA: success=False, error code = NOT_FOUND +def test_adv_remove_nonexistent_property(): + _clear() + from property_tracker import remove_property + result = _run(remove_property("nonexistent-id-999")) + assert result["success"] is False + assert isinstance(result["error"], dict) + assert result["error"]["code"] == "PROPERTY_TRACKER_NOT_FOUND" + + +# TYPE: adversarial +# INPUT: strategy with negative down payment percentage +# EXPECTED: uses fallback/default or handles gracefully +# CRITERIA: no crash +def test_adv_negative_down_payment_pct(): + from realestate_strategy import simulate_real_estate_strategy + try: + result = simulate_real_estate_strategy( + 94000, 120000, 400000, + down_payment_pct=-0.5, + ) + assert result is not None + except (ValueError, AssertionError): + pass # Validation rejection is fine + + +# TYPE: adversarial +# INPUT: empty string city for relocation +# EXPECTED: no crash, returns dict +# CRITERIA: result is a dict +def test_adv_empty_city_relocation(): + from relocation_runway import calculate_relocation_runway + try: + result = calculate_relocation_runway( + current_salary=100000, + offer_salary=120000, + current_city="", + destination_city="", + portfolio_value=50000, + ) + assert isinstance(result, dict) + except Exception: + pass # Failing gracefully is acceptable + + +# ============================================================================ +# MULTI-STEP TESTS (12 tests) +# ============================================================================ + +# TYPE: multi_step +# INPUT: add property → get total net worth → verify property appears +# EXPECTED: net worth increases by property equity after adding +# CRITERIA: total_net_worth > portfolio_value_alone +def test_ms_add_then_net_worth(): + _clear() + from property_tracker import add_property, get_total_net_worth + _run(add_property( + address="Multi Step Test", + purchase_price=350000, + current_value=420000, + mortgage_balance=280000, + )) # equity = 140000 + result = _run(get_total_net_worth(portfolio_value=94000)) + assert result["result"]["total_net_worth"] == pytest.approx(234000) + assert result["result"]["real_estate_equity"] == pytest.approx(140000) + + +# TYPE: multi_step +# INPUT: add property → analyze equity options +# EXPECTED: equity options reference added property, 3 scenarios returned +# CRITERIA: options has 3 entries, each has 10-year projection +def test_ms_add_then_equity_options(): + _clear() + from property_tracker import add_property, analyze_equity_options + prop = _run(add_property( + address="Equity Chain Test", + purchase_price=400000, + current_value=520000, + mortgage_balance=370000, + )) + result = analyze_equity_options(prop["result"]["property"]["id"]) + assert "options" in result + assert len(result["options"]) >= 3 + + +# TYPE: multi_step +# INPUT: add two properties → remove one → verify only one remains +# EXPECTED: after removal, list shows exactly 1 property +# CRITERIA: len(properties) == 1 after removal +def test_ms_add_two_remove_one(): + _clear() + from property_tracker import add_property, remove_property, get_properties + p1 = _run(add_property("Home 1", 400000, 450000, 300000)) + p2 = _run(add_property("Home 2", 350000, 400000, 250000)) + id1 = p1["result"]["property"]["id"] + _run(remove_property(id1)) + listed = _run(get_properties()) + props = listed["result"]["properties"] + assert len(props) == 1 + assert props[0]["id"] == p2["result"]["property"]["id"] + + +# TYPE: multi_step +# INPUT: strategy simulation → use final net worth in wealth position check +# EXPECTED: chaining results without errors +# CRITERIA: both return valid dicts, wealth check uses strategy output +def test_ms_strategy_then_wealth_check(): + from realestate_strategy import simulate_real_estate_strategy + from wealth_visualizer import analyze_wealth_position + strategy = simulate_real_estate_strategy(94000, 120000, 400000, total_years=10) + final_worth = strategy["final_picture"]["total_net_worth"] + wealth = analyze_wealth_position( + portfolio_value=final_worth, + age=44, + annual_income=150000, + ) + assert "current_position" in wealth + assert "retirement_projection" in wealth + + +# TYPE: multi_step +# INPUT: family planning → use reduced savings in wealth position +# EXPECTED: lower savings rate flows into retirement projection +# CRITERIA: both tools return valid dicts +def test_ms_family_then_wealth(): + from family_planner import plan_family_finances + from wealth_visualizer import analyze_wealth_position + family = plan_family_finances("Austin", 120000, num_planned_children=1) + assert "income_impact" in family + reduced_savings = max(0, family["income_impact"]["monthly_surplus_after"] * 12) + wealth = analyze_wealth_position( + portfolio_value=94000, + age=32, + annual_income=120000, + annual_savings=reduced_savings, + ) + assert "retirement_projection" in wealth + + +# TYPE: multi_step +# INPUT: job offer check → relocation runway → both use same cities +# EXPECTED: consistent data across both tools for Austin/Seattle +# CRITERIA: both return their respective fields correctly +@pytest.mark.asyncio +async def test_ms_job_offer_then_runway(): + from wealth_bridge import calculate_job_offer_affordability + from relocation_runway import calculate_relocation_runway + offer = await calculate_job_offer_affordability( + offer_salary=180000, + offer_city="Seattle", + current_salary=120000, + current_city="Austin", + ) + runway = calculate_relocation_runway( + current_salary=120000, + offer_salary=180000, + current_city="Austin", + destination_city="Seattle", + portfolio_value=94000, + ) + assert "is_real_raise" in offer + assert "verdict" in runway + + +# TYPE: multi_step +# INPUT: add property → update value → check net worth reflects update +# EXPECTED: net worth uses updated value not original +# CRITERIA: net worth after update > net worth after initial add +def test_ms_add_update_then_net_worth(): + _clear() + from property_tracker import add_property, update_property, get_total_net_worth + prop = _run(add_property("Growing Home", 400000, 450000, 320000)) + pid = prop["result"]["property"]["id"] + nw_before = _run(get_total_net_worth(portfolio_value=50000)) + _run(update_property(pid, current_value=500000)) + nw_after = _run(get_total_net_worth(portfolio_value=50000)) + assert nw_after["result"]["total_net_worth"] > nw_before["result"]["total_net_worth"] + + +# TYPE: multi_step +# INPUT: add property → get equity → use in wealth position +# EXPECTED: wealth position uses real estate equity from property tracker +# CRITERIA: position with RE equity > position without +def test_ms_property_equity_in_wealth_position(): + _clear() + from property_tracker import add_property, get_real_estate_equity + from wealth_visualizer import analyze_wealth_position + _run(add_property("Wealth Test", 400000, 500000, 300000)) # equity=200000 + equity_result = _run(get_real_estate_equity()) + equity = equity_result["result"]["total_real_estate_equity"] + pos_with_re = analyze_wealth_position(94000, 34, 120000, real_estate_equity=equity) + pos_without = analyze_wealth_position(94000, 34, 120000) + assert pos_with_re["current_position"]["total_net_worth"] > pos_without["current_position"]["total_net_worth"] + + +# TYPE: multi_step +# INPUT: full CRUD cycle — create, read, update, delete +# EXPECTED: each operation succeeds, state consistent throughout +# CRITERIA: all 4 operations return success=True, final list is empty +def test_ms_full_crud_cycle(): + _clear() + from property_tracker import add_property, get_properties, update_property, remove_property + + # CREATE + prop = _run(add_property("CRUD Home", 400000, 450000, 320000)) + assert prop["success"] is True + pid = prop["result"]["property"]["id"] + + # READ + listed = _run(get_properties()) + ids = [p["id"] for p in listed["result"]["properties"]] + assert pid in ids + + # UPDATE + updated = _run(update_property(pid, current_value=480000)) + assert updated["success"] is True + assert updated["result"]["property"]["equity"] == pytest.approx(160000) + + # DELETE + removed = _run(remove_property(pid)) + assert removed["success"] is True + + # Verify empty + after = _run(get_properties()) + assert after["result"]["properties"] == [] + + +# TYPE: multi_step +# INPUT: multiple properties → wealth position uses combined equity +# EXPECTED: total equity from all properties flows into net worth +# CRITERIA: net worth = portfolio + combined equity +def test_ms_multiple_properties_net_worth(): + _clear() + from property_tracker import add_property, get_total_net_worth + _run(add_property("Home A", 400000, 480000, 300000)) # equity=180000 + _run(add_property("Home B", 300000, 380000, 260000)) # equity=120000 + result = _run(get_total_net_worth(portfolio_value=94000)) + expected = 94000 + 180000 + 120000 # = 394000 + assert result["result"]["total_net_worth"] == pytest.approx(expected) + + +# TYPE: multi_step +# INPUT: relocation runway → family plan in destination city +# EXPECTED: can chain city data across both tools +# CRITERIA: both return valid dicts for Seattle +def test_ms_runway_then_family_plan(): + from relocation_runway import calculate_relocation_runway + from family_planner import plan_family_finances + runway = calculate_relocation_runway( + current_salary=120000, + offer_salary=180000, + current_city="Austin", + destination_city="Seattle", + portfolio_value=94000, + ) + assert "verdict" in runway + family = plan_family_finances("Seattle", 180000, num_planned_children=1) + assert "income_impact" in family + + +# TYPE: multi_step +# INPUT: strategy simulation over 20 years → verify timeline length +# EXPECTED: timeline has 20+ entries covering each year +# CRITERIA: len(timeline) >= 20 +def test_ms_strategy_long_horizon(): + from realestate_strategy import simulate_real_estate_strategy + result = simulate_real_estate_strategy( + 94000, 120000, 400000, + total_years=20, + buy_interval_years=4, + ) + assert result is not None + timeline = result.get("timeline", []) + assert len(timeline) >= 20 + assert result["final_picture"]["num_properties_owned"] >= 1