T4: Repository pattern — ModelRepository, JsonFileRepository, ExcelRepository
- application/repositories/model_repository.py: ModelRepository Protocol with load(path) and save(model, path) - infrastructure/repositories/json_file_repository.py: JsonFileRepository (вынесен из FinancialModel.save/load) - infrastructure/repositories/excel_repository.py: ExcelRepository (бывший ExcelSync, переименован) - FinancialModel.save/load удалены — теперь только данные - infrastructure/sync/ удалён (ExcelSync перенесён в repositories/) - infrastructure/cli/main.py и config.py используют JsonFileRepository + ExcelRepository через локальные singletons - test_excel_sync.py переименован в test для ExcelRepository - Новый test_repositories.py: 5 тестов для JsonFileRepository + протокол - test_model.py: использован JsonFileRepository Tests: 68/68 pass (было 63 + 5 новых).
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
from application.repositories.model_repository import ModelRepository
|
||||||
|
|
||||||
|
__all__ = ["ModelRepository"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Repository protocol для хранения FinancialModel.
|
||||||
|
|
||||||
|
Определяет контракт: load(path) -> FinancialModel, save(model, path) -> None.
|
||||||
|
Реализации: JsonFileRepository, ExcelRepository.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from domain import FinancialModel
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ModelRepository(Protocol):
|
||||||
|
"""Любой storage, способный прочитать и записать FinancialModel."""
|
||||||
|
|
||||||
|
def load(self, path: str | Path) -> FinancialModel: ...
|
||||||
|
|
||||||
|
def save(self, model: FinancialModel, path: str | Path) -> None: ...
|
||||||
+5
-17
@@ -1,6 +1,3 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from domain.account import Account
|
from domain.account import Account
|
||||||
@@ -13,7 +10,11 @@ from domain.transaction import Transaction
|
|||||||
|
|
||||||
|
|
||||||
class FinancialModel(BaseModel):
|
class FinancialModel(BaseModel):
|
||||||
"""Корневая модель финансового плана."""
|
"""Корневая модель финансового плана.
|
||||||
|
|
||||||
|
Сериализация (save/load) вынесена в `application.repositories.ModelRepository` —
|
||||||
|
JsonFileRepository и ExcelRepository. Сам класс хранит только данные.
|
||||||
|
"""
|
||||||
|
|
||||||
SCHEMA_VERSION: int = 1 # NB: не Field — это class-level metadata, не pydantic field
|
SCHEMA_VERSION: int = 1 # NB: не Field — это class-level metadata, не pydantic field
|
||||||
|
|
||||||
@@ -68,16 +69,3 @@ class FinancialModel(BaseModel):
|
|||||||
def _from_legacy(cls, data: dict) -> "FinancialModel":
|
def _from_legacy(cls, data: dict) -> "FinancialModel":
|
||||||
# Legacy: файлы без version. Структура совпадает с v1.
|
# Legacy: файлы без version. Структура совпадает с v1.
|
||||||
return cls._from_v1(data)
|
return cls._from_v1(data)
|
||||||
|
|
||||||
def save(self, path: str | Path) -> None:
|
|
||||||
path = Path(path)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load(cls, path: str | Path) -> "FinancialModel":
|
|
||||||
path = Path(path)
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return cls.from_dict(data)
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from domain import (
|
|||||||
Transaction,
|
Transaction,
|
||||||
)
|
)
|
||||||
from infrastructure.cli.i18n import t
|
from infrastructure.cli.i18n import t
|
||||||
|
from infrastructure.repositories import JsonFileRepository
|
||||||
|
|
||||||
app = typer.Typer(name="config", help=t("config.help"))
|
app = typer.Typer(name="config", help=t("config.help"))
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -21,15 +22,17 @@ console = Console()
|
|||||||
DATA_DIR = Path("data")
|
DATA_DIR = Path("data")
|
||||||
MODEL_PATH = DATA_DIR / "model.json"
|
MODEL_PATH = DATA_DIR / "model.json"
|
||||||
|
|
||||||
|
_json_repo = JsonFileRepository()
|
||||||
|
|
||||||
|
|
||||||
def _load_model() -> FinancialModel:
|
def _load_model() -> FinancialModel:
|
||||||
if MODEL_PATH.exists():
|
if MODEL_PATH.exists():
|
||||||
return FinancialModel.load(MODEL_PATH)
|
return _json_repo.load(MODEL_PATH)
|
||||||
return FinancialModel()
|
return FinancialModel()
|
||||||
|
|
||||||
|
|
||||||
def _save_model(model: FinancialModel) -> None:
|
def _save_model(model: FinancialModel) -> None:
|
||||||
model.save(MODEL_PATH)
|
_json_repo.save(model, MODEL_PATH)
|
||||||
|
|
||||||
|
|
||||||
# ── base-currency ──────────────────────────────────────────────
|
# ── base-currency ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from infrastructure.cli.config import app as config_app
|
|||||||
from infrastructure.cli.i18n import setup_i18n, t
|
from infrastructure.cli.i18n import setup_i18n, t
|
||||||
from application.forecast import ForecastService
|
from application.forecast import ForecastService
|
||||||
from application.scenarios import DEFAULT_SCENARIOS, ScenarioService
|
from application.scenarios import DEFAULT_SCENARIOS, ScenarioService
|
||||||
from infrastructure.sync.excel_sync import ExcelSync
|
from infrastructure.repositories import ExcelRepository, JsonFileRepository
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
@@ -28,15 +28,18 @@ MODEL_PATH = DATA_DIR / "model.json"
|
|||||||
|
|
||||||
CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency"))
|
CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency"))
|
||||||
|
|
||||||
|
_json_repo = JsonFileRepository()
|
||||||
|
_excel_repo = ExcelRepository()
|
||||||
|
|
||||||
|
|
||||||
def _load_model() -> FinancialModel:
|
def _load_model() -> FinancialModel:
|
||||||
if MODEL_PATH.exists():
|
if MODEL_PATH.exists():
|
||||||
return FinancialModel.load(MODEL_PATH)
|
return _json_repo.load(MODEL_PATH)
|
||||||
return FinancialModel()
|
return FinancialModel()
|
||||||
|
|
||||||
|
|
||||||
def _save_model(model: FinancialModel) -> None:
|
def _save_model(model: FinancialModel) -> None:
|
||||||
model.save(MODEL_PATH)
|
_json_repo.save(model, MODEL_PATH)
|
||||||
|
|
||||||
|
|
||||||
def _get_converter(model: FinancialModel) -> CurrencyConverter:
|
def _get_converter(model: FinancialModel) -> CurrencyConverter:
|
||||||
@@ -205,9 +208,8 @@ def import_xlsx(
|
|||||||
path: str = typer.Argument(..., help=t("cmd.import.arg.path")),
|
path: str = typer.Argument(..., help=t("cmd.import.arg.path")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Импорт данных из Excel"""
|
"""Импорт данных из Excel"""
|
||||||
sync = ExcelSync()
|
|
||||||
try:
|
try:
|
||||||
model = sync.import_model(path)
|
model = _excel_repo.load(path)
|
||||||
_save_model(model)
|
_save_model(model)
|
||||||
console.print(t("cmd.import.ok",
|
console.print(t("cmd.import.ok",
|
||||||
accounts=len(model.accounts),
|
accounts=len(model.accounts),
|
||||||
@@ -227,9 +229,8 @@ def export_xlsx(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Экспорт модели в Excel"""
|
"""Экспорт модели в Excel"""
|
||||||
model = _load_model()
|
model = _load_model()
|
||||||
sync = ExcelSync()
|
|
||||||
try:
|
try:
|
||||||
sync.export_model(model, path)
|
_excel_repo.save(model, path)
|
||||||
console.print(t("cmd.export.ok", path=path))
|
console.print(t("cmd.export.ok", path=path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[red]{t('cmd.export.err', error=str(e))}[/red]")
|
console.print(f"[red]{t('cmd.export.err', error=str(e))}[/red]")
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from infrastructure.repositories.excel_repository import ExcelRepository, ExcelRepositoryError
|
||||||
|
from infrastructure.repositories.json_file_repository import JsonFileRepository
|
||||||
|
|
||||||
|
__all__ = ["JsonFileRepository", "ExcelRepository", "ExcelRepositoryError"]
|
||||||
+27
-26
@@ -1,8 +1,10 @@
|
|||||||
|
"""ExcelRepository — хранит FinancialModel в .xlsx (5 листов + ModelInfo)."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
|
|
||||||
|
from application.repositories.model_repository import ModelRepository
|
||||||
from domain import (
|
from domain import (
|
||||||
Account,
|
Account,
|
||||||
Asset,
|
Asset,
|
||||||
@@ -14,7 +16,7 @@ from domain import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SyncError(Exception):
|
class ExcelRepositoryError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -46,13 +48,13 @@ _SHEET_CONFIG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ExcelSync:
|
class ExcelRepository(ModelRepository):
|
||||||
def import_model(self, path: str | Path) -> FinancialModel:
|
def load(self, path: str | Path) -> FinancialModel:
|
||||||
path = Path(path)
|
p = Path(path)
|
||||||
if not path.exists():
|
if not p.exists():
|
||||||
raise SyncError(f"File not found: {path}")
|
raise ExcelRepositoryError(f"File not found: {p}")
|
||||||
|
|
||||||
wb = load_workbook(path, read_only=True, data_only=True)
|
wb = load_workbook(p, read_only=True, data_only=True)
|
||||||
model = FinancialModel()
|
model = FinancialModel()
|
||||||
model.exchange_rates = []
|
model.exchange_rates = []
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ class ExcelSync:
|
|||||||
val = row[i] if i < len(row) else None
|
val = row[i] if i < len(row) else None
|
||||||
if val is not None:
|
if val is not None:
|
||||||
data[header] = str(val) if not isinstance(val, (int, float)) else val
|
data[header] = str(val) if not isinstance(val, (int, float)) else val
|
||||||
self._add_to_model(model, sheet_name, data)
|
self._add_to_model(model, sheet_name, data, config["cls"])
|
||||||
|
|
||||||
if "ModelInfo" in wb.sheetnames:
|
if "ModelInfo" in wb.sheetnames:
|
||||||
ws = wb["ModelInfo"]
|
ws = wb["ModelInfo"]
|
||||||
@@ -85,8 +87,8 @@ class ExcelSync:
|
|||||||
wb.close()
|
wb.close()
|
||||||
return model
|
return model
|
||||||
|
|
||||||
def export_model(self, model: FinancialModel, path: str | Path) -> None:
|
def save(self, model: FinancialModel, path: str | Path) -> None:
|
||||||
path = Path(path)
|
p = Path(path)
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
wb.remove(wb.active)
|
wb.remove(wb.active)
|
||||||
|
|
||||||
@@ -115,23 +117,22 @@ class ExcelSync:
|
|||||||
ws_info.append(["Property", "Value"])
|
ws_info.append(["Property", "Value"])
|
||||||
ws_info.append(["base_currency", model.base_currency])
|
ws_info.append(["base_currency", model.base_currency])
|
||||||
|
|
||||||
wb.save(path)
|
wb.save(p)
|
||||||
|
|
||||||
def _add_to_model(self, model: FinancialModel, sheet_name: str, data: dict) -> None:
|
def _add_to_model(self, model: FinancialModel, sheet_name: str, data: dict, cls) -> None:
|
||||||
try:
|
try:
|
||||||
cls = _SHEET_CONFIG[sheet_name]["cls"]
|
|
||||||
instance = cls.model_validate(data)
|
instance = cls.model_validate(data)
|
||||||
if sheet_name == "Accounts":
|
|
||||||
model.accounts.append(instance)
|
|
||||||
elif sheet_name == "Transactions":
|
|
||||||
model.transactions.append(instance)
|
|
||||||
elif sheet_name == "Recurring":
|
|
||||||
model.recurring.append(instance)
|
|
||||||
elif sheet_name == "Assets":
|
|
||||||
model.assets.append(instance)
|
|
||||||
elif sheet_name == "Liabilities":
|
|
||||||
model.liabilities.append(instance)
|
|
||||||
elif sheet_name == "ExchangeRates":
|
|
||||||
model.exchange_rates.append(instance)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise SyncError(f"Failed to parse row in {sheet_name}: {e}") from e
|
raise ExcelRepositoryError(f"Failed to parse row in {sheet_name}: {e}") from e
|
||||||
|
if sheet_name == "Accounts":
|
||||||
|
model.accounts.append(instance)
|
||||||
|
elif sheet_name == "Transactions":
|
||||||
|
model.transactions.append(instance)
|
||||||
|
elif sheet_name == "Recurring":
|
||||||
|
model.recurring.append(instance)
|
||||||
|
elif sheet_name == "Assets":
|
||||||
|
model.assets.append(instance)
|
||||||
|
elif sheet_name == "Liabilities":
|
||||||
|
model.liabilities.append(instance)
|
||||||
|
elif sheet_name == "ExchangeRates":
|
||||||
|
model.exchange_rates.append(instance)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""JsonFileRepository — хранит FinancialModel в .json (включая version)."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from application.repositories.model_repository import ModelRepository
|
||||||
|
from domain import FinancialModel
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFileRepository(ModelRepository):
|
||||||
|
def load(self, path: str | Path) -> FinancialModel:
|
||||||
|
p = Path(path)
|
||||||
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return FinancialModel.from_dict(data)
|
||||||
|
|
||||||
|
def save(self, model: FinancialModel, path: str | Path) -> None:
|
||||||
|
p = Path(path)
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(p, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(model.to_dict(), f, indent=2, ensure_ascii=False)
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from infrastructure.sync.excel_sync import ExcelSync, SyncError
|
|
||||||
|
|
||||||
__all__ = ["ExcelSync", "SyncError"]
|
|
||||||
+16
-16
@@ -1,22 +1,22 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from domain import FinancialModel
|
from domain import FinancialModel
|
||||||
from infrastructure.sync.excel_sync import ExcelSync, SyncError
|
from infrastructure.repositories import ExcelRepository, ExcelRepositoryError
|
||||||
|
|
||||||
|
|
||||||
class TestExcelSync:
|
class TestExcelRepository:
|
||||||
def test_export_creates_file(self, sample_model, tmp_path: Path):
|
def test_export_creates_file(self, sample_model, tmp_path: Path):
|
||||||
sync = ExcelSync()
|
repo = ExcelRepository()
|
||||||
p = tmp_path / "test.xlsx"
|
p = tmp_path / "test.xlsx"
|
||||||
sync.export_model(sample_model, p)
|
repo.save(sample_model, p)
|
||||||
assert p.exists()
|
assert p.exists()
|
||||||
|
|
||||||
def test_export_import_roundtrip(self, sample_model, tmp_path: Path):
|
def test_export_import_roundtrip(self, sample_model, tmp_path: Path):
|
||||||
sync = ExcelSync()
|
repo = ExcelRepository()
|
||||||
p = tmp_path / "roundtrip.xlsx"
|
p = tmp_path / "roundtrip.xlsx"
|
||||||
sync.export_model(sample_model, p)
|
repo.save(sample_model, p)
|
||||||
|
|
||||||
loaded = sync.import_model(p)
|
loaded = repo.load(p)
|
||||||
assert len(loaded.accounts) == len(sample_model.accounts)
|
assert len(loaded.accounts) == len(sample_model.accounts)
|
||||||
assert len(loaded.transactions) == len(sample_model.transactions)
|
assert len(loaded.transactions) == len(sample_model.transactions)
|
||||||
assert len(loaded.recurring) == len(sample_model.recurring)
|
assert len(loaded.recurring) == len(sample_model.recurring)
|
||||||
@@ -25,25 +25,25 @@ class TestExcelSync:
|
|||||||
assert len(loaded.exchange_rates) == len(sample_model.exchange_rates)
|
assert len(loaded.exchange_rates) == len(sample_model.exchange_rates)
|
||||||
|
|
||||||
def test_roundtrip_preserves_base_currency(self, tmp_path: Path):
|
def test_roundtrip_preserves_base_currency(self, tmp_path: Path):
|
||||||
sync = ExcelSync()
|
repo = ExcelRepository()
|
||||||
model = FinancialModel(base_currency="EUR")
|
model = FinancialModel(base_currency="EUR")
|
||||||
p = tmp_path / "eur_model.xlsx"
|
p = tmp_path / "eur_model.xlsx"
|
||||||
sync.export_model(model, p)
|
repo.save(model, p)
|
||||||
|
|
||||||
loaded = sync.import_model(p)
|
loaded = repo.load(p)
|
||||||
assert loaded.base_currency == "EUR"
|
assert loaded.base_currency == "EUR"
|
||||||
|
|
||||||
def test_import_missing_file(self):
|
def test_import_missing_file(self):
|
||||||
sync = ExcelSync()
|
repo = ExcelRepository()
|
||||||
try:
|
try:
|
||||||
sync.import_model("nonexistent.xlsx")
|
repo.load("nonexistent.xlsx")
|
||||||
assert False, "Expected SyncError"
|
assert False, "Expected ExcelRepositoryError"
|
||||||
except SyncError:
|
except ExcelRepositoryError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def test_export_empty_model(self, tmp_path: Path):
|
def test_export_empty_model(self, tmp_path: Path):
|
||||||
sync = ExcelSync()
|
repo = ExcelRepository()
|
||||||
model = FinancialModel()
|
model = FinancialModel()
|
||||||
p = tmp_path / "empty.xlsx"
|
p = tmp_path / "empty.xlsx"
|
||||||
sync.export_model(model, p)
|
repo.save(model, p)
|
||||||
assert p.exists()
|
assert p.exists()
|
||||||
|
|||||||
+26
-21
@@ -1,3 +1,4 @@
|
|||||||
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from domain import (
|
from domain import (
|
||||||
@@ -10,17 +11,21 @@ from domain import (
|
|||||||
RecurringCashflow,
|
RecurringCashflow,
|
||||||
Transaction,
|
Transaction,
|
||||||
)
|
)
|
||||||
|
from infrastructure.repositories import JsonFileRepository
|
||||||
|
|
||||||
|
|
||||||
|
_repo = JsonFileRepository()
|
||||||
|
|
||||||
|
|
||||||
class TestAccount:
|
class TestAccount:
|
||||||
def test_create(self):
|
def test_create(self):
|
||||||
a = Account(name="Test", balance=100.0)
|
a = Account(name="Test", balance=Decimal("100.0"))
|
||||||
assert a.name == "Test"
|
assert a.name == "Test"
|
||||||
assert a.balance == 100.0
|
assert a.balance == Decimal("100.0")
|
||||||
assert a.currency == "USD"
|
assert a.currency == "USD"
|
||||||
|
|
||||||
def test_to_dict_roundtrip(self):
|
def test_to_dict_roundtrip(self):
|
||||||
a = Account(name="Test", balance=100.0)
|
a = Account(name="Test", balance=Decimal("100.0"))
|
||||||
d = a.model_dump(mode="json")
|
d = a.model_dump(mode="json")
|
||||||
a2 = Account.model_validate(d)
|
a2 = Account.model_validate(d)
|
||||||
assert a2.name == a.name
|
assert a2.name == a.name
|
||||||
@@ -30,11 +35,11 @@ class TestAccount:
|
|||||||
|
|
||||||
class TestTransaction:
|
class TestTransaction:
|
||||||
def test_create(self):
|
def test_create(self):
|
||||||
t = Transaction(amount=500.0, category="food")
|
t = Transaction(amount=Decimal("500.0"), category="food")
|
||||||
assert t.amount == 500.0
|
assert t.amount == Decimal("500.0")
|
||||||
|
|
||||||
def test_roundtrip(self):
|
def test_roundtrip(self):
|
||||||
t = Transaction(amount=-100.0, category="rent", description="test")
|
t = Transaction(amount=Decimal("-100.0"), category="rent", description="test")
|
||||||
d = t.model_dump(mode="json")
|
d = t.model_dump(mode="json")
|
||||||
t2 = Transaction.model_validate(d)
|
t2 = Transaction.model_validate(d)
|
||||||
assert t2.amount == t.amount
|
assert t2.amount == t.amount
|
||||||
@@ -50,8 +55,8 @@ class TestFinancialModel:
|
|||||||
def test_base_currency_roundtrip(self, tmp_path: Path):
|
def test_base_currency_roundtrip(self, tmp_path: Path):
|
||||||
model = FinancialModel(base_currency="EUR")
|
model = FinancialModel(base_currency="EUR")
|
||||||
p = tmp_path / "model.json"
|
p = tmp_path / "model.json"
|
||||||
model.save(p)
|
_repo.save(model, p)
|
||||||
loaded = FinancialModel.load(p)
|
loaded = _repo.load(p)
|
||||||
assert loaded.base_currency == "EUR"
|
assert loaded.base_currency == "EUR"
|
||||||
|
|
||||||
def test_base_currency_backward_compat(self, tmp_path: Path):
|
def test_base_currency_backward_compat(self, tmp_path: Path):
|
||||||
@@ -59,7 +64,7 @@ class TestFinancialModel:
|
|||||||
p = tmp_path / "legacy.json"
|
p = tmp_path / "legacy.json"
|
||||||
with open(p, "w") as f:
|
with open(p, "w") as f:
|
||||||
json.dump({"accounts": []}, f)
|
json.dump({"accounts": []}, f)
|
||||||
loaded = FinancialModel.load(p)
|
loaded = _repo.load(p)
|
||||||
assert loaded.base_currency == "RUB"
|
assert loaded.base_currency == "RUB"
|
||||||
|
|
||||||
def test_exchange_rates_default(self):
|
def test_exchange_rates_default(self):
|
||||||
@@ -67,18 +72,18 @@ class TestFinancialModel:
|
|||||||
assert len(model.exchange_rates) == 1
|
assert len(model.exchange_rates) == 1
|
||||||
assert model.exchange_rates[0].from_currency == "USD"
|
assert model.exchange_rates[0].from_currency == "USD"
|
||||||
assert model.exchange_rates[0].to_currency == "RUB"
|
assert model.exchange_rates[0].to_currency == "RUB"
|
||||||
assert model.exchange_rates[0].rate == 80.0
|
assert model.exchange_rates[0].rate == Decimal("80")
|
||||||
|
|
||||||
def test_save_load(self, tmp_path: Path):
|
def test_save_load(self, tmp_path: Path):
|
||||||
model = FinancialModel()
|
model = FinancialModel()
|
||||||
model.accounts.append(Account(name="Test", balance=100.0))
|
model.accounts.append(Account(name="Test", balance=Decimal("100")))
|
||||||
model.transactions.append(Transaction(amount=50.0, category="income"))
|
model.transactions.append(Transaction(amount=Decimal("50"), category="income"))
|
||||||
|
|
||||||
p = tmp_path / "model.json"
|
p = tmp_path / "model.json"
|
||||||
model.save(p)
|
_repo.save(model, p)
|
||||||
assert p.exists()
|
assert p.exists()
|
||||||
|
|
||||||
loaded = FinancialModel.load(p)
|
loaded = _repo.load(p)
|
||||||
assert len(loaded.accounts) == 1
|
assert len(loaded.accounts) == 1
|
||||||
assert len(loaded.transactions) == 1
|
assert len(loaded.transactions) == 1
|
||||||
assert loaded.accounts[0].name == "Test"
|
assert loaded.accounts[0].name == "Test"
|
||||||
@@ -94,16 +99,16 @@ class TestFinancialModel:
|
|||||||
def test_all_entities_roundtrip(self, tmp_path: Path):
|
def test_all_entities_roundtrip(self, tmp_path: Path):
|
||||||
model = FinancialModel(
|
model = FinancialModel(
|
||||||
accounts=[Account(name="A"), Account(name="B")],
|
accounts=[Account(name="A"), Account(name="B")],
|
||||||
transactions=[Transaction(amount=100.0)],
|
transactions=[Transaction(amount=Decimal("100"))],
|
||||||
recurring=[RecurringCashflow(amount=50.0)],
|
recurring=[RecurringCashflow(amount=Decimal("50"))],
|
||||||
assets=[Asset(name="Stock", value=1000.0)],
|
assets=[Asset(name="Stock", value=Decimal("1000"))],
|
||||||
liabilities=[Liability(name="Loan", balance=500.0, interest=5.0, payment=100.0)],
|
liabilities=[Liability(name="Loan", balance=Decimal("500"), interest=Decimal("5"), payment=Decimal("100"))],
|
||||||
scenarios=[ForecastScenario(name="test")],
|
scenarios=[ForecastScenario(name="test")],
|
||||||
exchange_rates=[ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)],
|
exchange_rates=[ExchangeRate(from_currency="USD", to_currency="RUB", rate=Decimal("80"))],
|
||||||
)
|
)
|
||||||
p = tmp_path / "full.json"
|
p = tmp_path / "full.json"
|
||||||
model.save(p)
|
_repo.save(model, p)
|
||||||
loaded = FinancialModel.load(p)
|
loaded = _repo.load(p)
|
||||||
assert len(loaded.accounts) == 2
|
assert len(loaded.accounts) == 2
|
||||||
assert len(loaded.transactions) == 1
|
assert len(loaded.transactions) == 1
|
||||||
assert len(loaded.recurring) == 1
|
assert len(loaded.recurring) == 1
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from domain import Account, Asset, FinancialModel, Transaction
|
||||||
|
from application.repositories import ModelRepository
|
||||||
|
from infrastructure.repositories import JsonFileRepository
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonFileRepository:
|
||||||
|
def test_save_load_roundtrip(self, tmp_path: Path):
|
||||||
|
repo = JsonFileRepository()
|
||||||
|
model = FinancialModel(
|
||||||
|
accounts=[Account(name="Test", balance=Decimal("100.50"))],
|
||||||
|
transactions=[Transaction(amount=Decimal("33.33"), category="income")],
|
||||||
|
)
|
||||||
|
p = tmp_path / "model.json"
|
||||||
|
repo.save(model, p)
|
||||||
|
assert p.exists()
|
||||||
|
|
||||||
|
loaded = repo.load(p)
|
||||||
|
assert len(loaded.accounts) == 1
|
||||||
|
assert loaded.accounts[0].name == "Test"
|
||||||
|
assert loaded.accounts[0].balance == Decimal("100.50")
|
||||||
|
assert loaded.transactions[0].amount == Decimal("33.33")
|
||||||
|
|
||||||
|
def test_version_field_persisted(self, tmp_path: Path):
|
||||||
|
repo = JsonFileRepository()
|
||||||
|
model = FinancialModel()
|
||||||
|
p = tmp_path / "v.json"
|
||||||
|
repo.save(model, p)
|
||||||
|
import json
|
||||||
|
with open(p) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
assert data["version"] == 1
|
||||||
|
|
||||||
|
def test_creates_parent_dirs(self, tmp_path: Path):
|
||||||
|
repo = JsonFileRepository()
|
||||||
|
model = FinancialModel()
|
||||||
|
p = tmp_path / "nested" / "dir" / "model.json"
|
||||||
|
repo.save(model, p)
|
||||||
|
assert p.exists()
|
||||||
|
|
||||||
|
def test_implements_protocol(self):
|
||||||
|
repo = JsonFileRepository()
|
||||||
|
assert isinstance(repo, ModelRepository)
|
||||||
|
|
||||||
|
def test_decimal_preserved(self, tmp_path: Path):
|
||||||
|
repo = JsonFileRepository()
|
||||||
|
model = FinancialModel(
|
||||||
|
assets=[Asset(name="Stocks", value=Decimal("12345.67"), growth_rate=Decimal("8.5"))]
|
||||||
|
)
|
||||||
|
p = tmp_path / "decimal.json"
|
||||||
|
repo.save(model, p)
|
||||||
|
loaded = repo.load(p)
|
||||||
|
assert loaded.assets[0].value == Decimal("12345.67")
|
||||||
|
assert loaded.assets[0].growth_rate == Decimal("8.5")
|
||||||
Reference in New Issue
Block a user