bootstrap
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from cashflow_model import (
|
||||
Account,
|
||||
Asset,
|
||||
FinancialModel,
|
||||
Liability,
|
||||
RecurringCashflow,
|
||||
Transaction,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_model() -> FinancialModel:
|
||||
acc_id = uuid4()
|
||||
return FinancialModel(
|
||||
accounts=[
|
||||
Account(id=acc_id, name="Основной счёт", currency="USD", balance=5000.0),
|
||||
Account(name="Сбережения", currency="USD", balance=10000.0),
|
||||
],
|
||||
transactions=[
|
||||
Transaction(
|
||||
date="2026-01-01", account=str(acc_id),
|
||||
category="income", amount=3000.0,
|
||||
description="Зарплата",
|
||||
),
|
||||
Transaction(
|
||||
date="2026-01-05", account=str(acc_id),
|
||||
category="rent", amount=-1200.0,
|
||||
description="Аренда",
|
||||
),
|
||||
],
|
||||
recurring=[
|
||||
RecurringCashflow(
|
||||
start_date="2026-01-01", frequency="monthly",
|
||||
amount=500.0, category="income",
|
||||
),
|
||||
RecurringCashflow(
|
||||
start_date="2026-01-01", frequency="monthly",
|
||||
amount=-200.0, category="subscription",
|
||||
),
|
||||
],
|
||||
assets=[
|
||||
Asset(name="Акции", value=50000.0, growth_rate=8.0),
|
||||
],
|
||||
liabilities=[
|
||||
Liability(name="Кредит", balance=20000.0, interest=5.0, payment=500.0),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_model() -> FinancialModel:
|
||||
return FinancialModel()
|
||||
@@ -0,0 +1,24 @@
|
||||
from ai.assistant import AssistantService
|
||||
from ai.prompts import ADVICE_PROMPT, ANALYZE_PROMPT
|
||||
|
||||
|
||||
class TestAssistantService:
|
||||
def test_analyze_returns_prompt(self, sample_model):
|
||||
assistant = AssistantService(sample_model)
|
||||
result = assistant.analyze(months=6)
|
||||
assert "prompt" in result
|
||||
assert "summary" in result
|
||||
assert "forecast" in result
|
||||
assert result["ai_response"] is None
|
||||
|
||||
def test_advice_returns_prompt(self, sample_model):
|
||||
assistant = AssistantService(sample_model)
|
||||
result = assistant.advice("Как мне сэкономить?", months=6)
|
||||
assert "prompt" in result
|
||||
assert "ai_response" in result
|
||||
|
||||
def test_prompt_templates(self):
|
||||
assert "{model_json}" in ANALYZE_PROMPT
|
||||
assert "{forecast_json}" in ANALYZE_PROMPT
|
||||
assert "{model_json}" in ADVICE_PROMPT
|
||||
assert "{question}" in ADVICE_PROMPT
|
||||
@@ -0,0 +1,25 @@
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_init(self):
|
||||
result = runner.invoke(app, ["init"])
|
||||
assert result.exit_code == 0
|
||||
assert "Пустая модель" in result.stdout
|
||||
|
||||
def test_forecast_after_init(self):
|
||||
runner.invoke(app, ["init"])
|
||||
result = runner.invoke(app, ["forecast", "--months", "3"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_unknown_scenario(self):
|
||||
result = runner.invoke(app, ["scenario", "unknown"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_help(self):
|
||||
result = runner.invoke(app, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
@@ -0,0 +1,39 @@
|
||||
from pathlib import Path
|
||||
|
||||
from cashflow_model import FinancialModel
|
||||
from sync.excel_sync import ExcelSync, SyncError
|
||||
|
||||
|
||||
class TestExcelSync:
|
||||
def test_export_creates_file(self, sample_model, tmp_path: Path):
|
||||
sync = ExcelSync()
|
||||
p = tmp_path / "test.xlsx"
|
||||
sync.export_model(sample_model, p)
|
||||
assert p.exists()
|
||||
|
||||
def test_export_import_roundtrip(self, sample_model, tmp_path: Path):
|
||||
sync = ExcelSync()
|
||||
p = tmp_path / "roundtrip.xlsx"
|
||||
sync.export_model(sample_model, p)
|
||||
|
||||
loaded = sync.import_model(p)
|
||||
assert len(loaded.accounts) == len(sample_model.accounts)
|
||||
assert len(loaded.transactions) == len(sample_model.transactions)
|
||||
assert len(loaded.recurring) == len(sample_model.recurring)
|
||||
assert len(loaded.assets) == len(sample_model.assets)
|
||||
assert len(loaded.liabilities) == len(sample_model.liabilities)
|
||||
|
||||
def test_import_missing_file(self):
|
||||
sync = ExcelSync()
|
||||
try:
|
||||
sync.import_model("nonexistent.xlsx")
|
||||
assert False, "Expected SyncError"
|
||||
except SyncError:
|
||||
pass
|
||||
|
||||
def test_export_empty_model(self, tmp_path: Path):
|
||||
sync = ExcelSync()
|
||||
model = FinancialModel()
|
||||
p = tmp_path / "empty.xlsx"
|
||||
sync.export_model(model, p)
|
||||
assert p.exists()
|
||||
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
|
||||
from engine.forecast import ForecastError, ForecastService
|
||||
|
||||
|
||||
class TestForecastService:
|
||||
def test_forecast_returns_results(self, sample_model):
|
||||
service = ForecastService(sample_model)
|
||||
results = service.forecast_cashflow(months=12)
|
||||
assert len(results) > 0
|
||||
assert "balance" in results[0]
|
||||
assert "month" in results[0]
|
||||
|
||||
def test_forecast_12_months(self, sample_model):
|
||||
service = ForecastService(sample_model)
|
||||
results = service.forecast_cashflow(months=12)
|
||||
months = set(r["month"] for r in results)
|
||||
assert max(months) == 12
|
||||
|
||||
def test_invalid_months(self, sample_model):
|
||||
service = ForecastService(sample_model)
|
||||
with pytest.raises(ForecastError):
|
||||
service.forecast_cashflow(months=0)
|
||||
|
||||
def test_summary(self, sample_model):
|
||||
service = ForecastService(sample_model)
|
||||
s = service.summary(months=6)
|
||||
assert "total_balance" in s
|
||||
assert "total_income" in s
|
||||
assert "total_expenses" in s
|
||||
assert s["months"] == 6
|
||||
|
||||
def test_empty_model(self, empty_model):
|
||||
service = ForecastService(empty_model)
|
||||
results = service.forecast_cashflow(months=3)
|
||||
assert results == []
|
||||
@@ -0,0 +1,82 @@
|
||||
from pathlib import Path
|
||||
|
||||
from cashflow_model import (
|
||||
Account,
|
||||
Asset,
|
||||
FinancialModel,
|
||||
ForecastScenario,
|
||||
Liability,
|
||||
RecurringCashflow,
|
||||
Transaction,
|
||||
)
|
||||
|
||||
|
||||
class TestAccount:
|
||||
def test_create(self):
|
||||
a = Account(name="Test", balance=100.0)
|
||||
assert a.name == "Test"
|
||||
assert a.balance == 100.0
|
||||
assert a.currency == "USD"
|
||||
|
||||
def test_to_dict_roundtrip(self):
|
||||
a = Account(name="Test", balance=100.0)
|
||||
d = a.to_dict()
|
||||
a2 = Account.from_dict(d)
|
||||
assert a2.name == a.name
|
||||
assert a2.balance == a.balance
|
||||
assert a2.currency == a.currency
|
||||
|
||||
|
||||
class TestTransaction:
|
||||
def test_create(self):
|
||||
t = Transaction(amount=500.0, category="food")
|
||||
assert t.amount == 500.0
|
||||
|
||||
def test_roundtrip(self):
|
||||
t = Transaction(amount=-100.0, category="rent", description="test")
|
||||
d = t.to_dict()
|
||||
t2 = Transaction.from_dict(d)
|
||||
assert t2.amount == t.amount
|
||||
assert t2.category == t.category
|
||||
assert t2.description == t.description
|
||||
|
||||
|
||||
class TestFinancialModel:
|
||||
def test_save_load(self, tmp_path: Path):
|
||||
model = FinancialModel()
|
||||
model.accounts.append(Account(name="Test", balance=100.0))
|
||||
model.transactions.append(Transaction(amount=50.0, category="income"))
|
||||
|
||||
p = tmp_path / "model.json"
|
||||
model.save(p)
|
||||
assert p.exists()
|
||||
|
||||
loaded = FinancialModel.load(p)
|
||||
assert len(loaded.accounts) == 1
|
||||
assert len(loaded.transactions) == 1
|
||||
assert loaded.accounts[0].name == "Test"
|
||||
|
||||
def test_empty_model(self):
|
||||
model = FinancialModel()
|
||||
d = model.to_dict()
|
||||
assert d["accounts"] == []
|
||||
assert d["transactions"] == []
|
||||
|
||||
def test_all_entities_roundtrip(self, tmp_path: Path):
|
||||
model = FinancialModel(
|
||||
accounts=[Account(name="A"), Account(name="B")],
|
||||
transactions=[Transaction(amount=100.0)],
|
||||
recurring=[RecurringCashflow(amount=50.0)],
|
||||
assets=[Asset(name="Stock", value=1000.0)],
|
||||
liabilities=[Liability(name="Loan", balance=500.0, interest=5.0, payment=100.0)],
|
||||
scenarios=[ForecastScenario(name="test")],
|
||||
)
|
||||
p = tmp_path / "full.json"
|
||||
model.save(p)
|
||||
loaded = FinancialModel.load(p)
|
||||
assert len(loaded.accounts) == 2
|
||||
assert len(loaded.transactions) == 1
|
||||
assert len(loaded.recurring) == 1
|
||||
assert len(loaded.assets) == 1
|
||||
assert len(loaded.liabilities) == 1
|
||||
assert len(loaded.scenarios) == 1
|
||||
@@ -0,0 +1,21 @@
|
||||
from engine.scenarios import DEFAULT_SCENARIOS, ScenarioService
|
||||
|
||||
|
||||
class TestScenarioService:
|
||||
def test_baseline(self, sample_model):
|
||||
service = ScenarioService(sample_model)
|
||||
result = service.apply(DEFAULT_SCENARIOS["baseline"], months=6)
|
||||
assert result["scenario"] == "baseline"
|
||||
assert result["total_balance"] is not None
|
||||
|
||||
def test_compare_returns_three(self, sample_model):
|
||||
service = ScenarioService(sample_model)
|
||||
results = service.compare(months=6)
|
||||
assert "baseline" in results
|
||||
assert "optimistic" in results
|
||||
assert "pessimistic" in results
|
||||
|
||||
def test_what_if(self, sample_model):
|
||||
service = ScenarioService(sample_model)
|
||||
result = service.what_if(income_mult=1.2, expense_mult=0.9, months=6)
|
||||
assert result["scenario"] == "what-if"
|
||||
Reference in New Issue
Block a user