mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
965 lines
35 KiB
965 lines
35 KiB
"""
|
|
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
|
|
|
|
|
|
# TYPE: happy_path
|
|
# INPUT: simple portfolio query timed end to end
|
|
# EXPECTED: tool executes within 30 seconds
|
|
# CRITERIA: elapsed time under 30s — not a performance
|
|
# gate but confirms agent responds at all
|
|
def test_latency_agent_responds_within_bounds():
|
|
"""Verify agent tools respond within acceptable
|
|
time bounds. LLM synthesis is excluded from this
|
|
test — we test tool execution speed only."""
|
|
import time
|
|
from property_tracker import get_properties
|
|
|
|
start = time.time()
|
|
result = _run(get_properties())
|
|
elapsed = time.time() - start
|
|
|
|
assert result is not None
|
|
assert elapsed < 5.0, (
|
|
f"Tool execution took {elapsed:.2f}s — "
|
|
f"should be under 5s. LLM synthesis latency "
|
|
f"(8-10s) is separate and documented."
|
|
)
|
|
|