bootstrap

This commit is contained in:
2026-07-12 19:46:07 +03:00
parent 2ddf2251d6
commit 940d25435e
36 changed files with 1914 additions and 0 deletions
View File
+56
View File
@@ -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()
+24
View File
@@ -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
+25
View File
@@ -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
+39
View File
@@ -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()
+36
View File
@@ -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 == []
+82
View File
@@ -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
+21
View File
@@ -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"