New `expenses_handlers.py`: - parse_expense(phrase) -> (amount, category) | None — pure fn, fully unit-tested. Handles "потратил 500 на еду", comma decimals "12,50", EN "spent 12.5 on coffee", default category "разное". - do_expenses_log: append entry to expenses.json (atomic write-then-rename). - do_expenses_today: sum today's entries (local-day boundary). - do_expenses_week: rolling 7-day total + count. - do_expenses_breakdown: per-category, top-5 + tail collapse. extensions.py / main.py / commands.yaml: 4 new dispatch entries. .gitignore: + expenses.json. tests/test_expenses_handlers.py: 8 unit tests covering parse, log, today/week math, breakdown ordering + tail collapse. Tests: 96 → 104 (+8). Total Python coverage now mirrors Rust pack behaviour for personal finance.
116 lines
4 KiB
Python
116 lines
4 KiB
Python
"""Tests for `expenses_handlers.py` — voice-driven personal finance tracker."""
|
|
|
|
import os
|
|
import sys
|
|
import time as _time
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
def _capture(eh):
|
|
captured = []
|
|
eh.set_speak(captured.append)
|
|
return captured
|
|
|
|
|
|
def test_parse_expense_extracts_amount_and_category(isolated_state):
|
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
|
assert eh.parse_expense("потратил 500 на еду") == (500.0, "еду")
|
|
assert eh.parse_expense("spent 12.50 on coffee") == (12.5, "coffee")
|
|
# Comma decimal (RU keyboard)
|
|
assert eh.parse_expense("потратил 12,50 на транспорт") == (12.5, "транспорт")
|
|
# No category → default
|
|
amount, cat = eh.parse_expense("потратил 100")
|
|
assert amount == 100.0
|
|
assert cat == "разное"
|
|
# Garbage
|
|
assert eh.parse_expense("какая погода") is None
|
|
assert eh.parse_expense("потратил bla") is None
|
|
assert eh.parse_expense("потратил -100") is None
|
|
assert eh.parse_expense("") is None
|
|
|
|
|
|
def test_log_appends_entry(isolated_state):
|
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
|
captured = _capture(eh)
|
|
eh.do_expenses_log(voice="потратил 250 на кофе")
|
|
data = eh.load()
|
|
assert len(data['entries']) == 1
|
|
e = data['entries'][0]
|
|
assert e['amount'] == 250.0
|
|
assert e['category'] == "кофе"
|
|
assert "250" in captured[0]
|
|
assert "кофе" in captured[0]
|
|
|
|
|
|
def test_log_ignores_unparseable(isolated_state):
|
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
|
captured = _capture(eh)
|
|
eh.do_expenses_log(voice="ну блин")
|
|
assert eh.load()['entries'] == []
|
|
assert "не понял" in captured[0].lower()
|
|
|
|
|
|
def test_today_sums_only_today(isolated_state):
|
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
|
# Seed data: one entry today, one entry yesterday
|
|
now = int(_time.time())
|
|
eh._save({'entries': [
|
|
{'ts': now, 'amount': 100.0, 'category': 'еда'},
|
|
{'ts': now - 86400, 'amount': 999.0, 'category': 'старое'},
|
|
]})
|
|
captured = _capture(eh)
|
|
eh.do_expenses_today()
|
|
assert "100" in captured[0]
|
|
assert "999" not in captured[0]
|
|
|
|
|
|
def test_week_sums_last_7_days(isolated_state):
|
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
|
now = int(_time.time())
|
|
eh._save({'entries': [
|
|
{'ts': now - 3 * 86400, 'amount': 100.0, 'category': 'a'},
|
|
{'ts': now - 5 * 86400, 'amount': 200.0, 'category': 'b'},
|
|
{'ts': now - 10 * 86400, 'amount': 999.0, 'category': 'старое'},
|
|
]})
|
|
captured = _capture(eh)
|
|
eh.do_expenses_week()
|
|
# 100 + 200 = 300 in last 7 days; 999 excluded
|
|
assert "300" in captured[0]
|
|
assert "999" not in captured[0]
|
|
assert "2 трат" in captured[0]
|
|
|
|
|
|
def test_breakdown_sorts_by_amount(isolated_state):
|
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
|
now = int(_time.time())
|
|
eh._save({'entries': [
|
|
{'ts': now, 'amount': 100, 'category': 'small'},
|
|
{'ts': now, 'amount': 500, 'category': 'big'},
|
|
{'ts': now, 'amount': 200, 'category': 'medium'},
|
|
]})
|
|
captured = _capture(eh)
|
|
eh.do_expenses_breakdown()
|
|
text = captured[0]
|
|
# big should appear before medium before small
|
|
assert text.index("big") < text.index("medium") < text.index("small")
|
|
|
|
|
|
def test_breakdown_collapses_tail(isolated_state):
|
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
|
now = int(_time.time())
|
|
eh._save({'entries': [
|
|
{'ts': now, 'amount': float(i + 1) * 10, 'category': f'c{i}'}
|
|
for i in range(8) # 8 categories — top 5 spoken, 3 collapse
|
|
]})
|
|
captured = _capture(eh)
|
|
eh.do_expenses_breakdown()
|
|
assert "и другое" in captured[0]
|
|
|
|
|
|
def test_today_when_empty_says_so(isolated_state):
|
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
|
captured = _capture(eh)
|
|
eh.do_expenses_today()
|
|
assert "ничего" in captured[0].lower()
|