Spaces:
Running on Zero
Running on Zero
| from datetime import date | |
| import pytest | |
| from finbot.parser import parse_date_hint, parse_expense | |
| TODAY = date(2026, 8, 15) # a Saturday | |
| class TestParseDateHint: | |
| def test_defaults_to_today(self): | |
| resolved, span = parse_date_hint("coffee 250", TODAY) | |
| assert resolved == TODAY | |
| assert span is None | |
| def test_resolves(self, text, expected): | |
| resolved, span = parse_date_hint(text, TODAY) | |
| assert resolved == expected | |
| assert span is not None | |
| def test_future_dates_roll_back_a_year(self): | |
| # "12 dec" typed in August means last December, not a future spend. | |
| resolved, _ = parse_date_hint("gift 12 dec", TODAY) | |
| assert resolved == date(2025, 12, 12) | |
| def test_bare_weekday_means_the_most_recent_past_one(self): | |
| # TODAY is a Saturday; "friday" is yesterday. | |
| resolved, _ = parse_date_hint("drinks friday", TODAY) | |
| assert resolved == date(2026, 8, 14) | |
| def test_same_weekday_goes_back_a_full_week(self): | |
| resolved, _ = parse_date_hint("shopping saturday", TODAY) | |
| assert resolved == date(2026, 8, 8) | |
| def test_day_first_preferred_for_ambiguous_slashes(self): | |
| resolved, _ = parse_date_hint("paid 03/04", TODAY) | |
| assert resolved == date(2026, 4, 3) | |
| def test_falls_back_to_month_first_when_day_first_impossible(self): | |
| # 25 is not a month, so 12/25 can only be December 25th. | |
| resolved, _ = parse_date_hint("gift 12/25", TODAY) | |
| assert resolved == date(2025, 12, 25) | |
| def test_invalid_date_does_not_crash(self): | |
| resolved, _ = parse_date_hint("paid 2026-02-30", TODAY) | |
| assert resolved == TODAY | |
| class TestParseExpense: | |
| def test_returns_none_without_an_amount(self): | |
| assert parse_expense("hello there", "INR", TODAY) is None | |
| def test_returns_none_for_empty(self): | |
| assert parse_expense("", "INR", TODAY) is None | |
| assert parse_expense(" ", "INR", TODAY) is None | |
| def test_simple_entry(self): | |
| draft = parse_expense("coffee 250", "INR", TODAY) | |
| assert draft is not None | |
| assert draft.amount_minor == 25000 | |
| assert draft.currency == "INR" | |
| assert "coffee" in draft.description | |
| assert draft.category == "food_dining" | |
| assert draft.occurred_on == TODAY | |
| def test_strips_the_amount_from_the_description(self): | |
| draft = parse_expense("₹1,200 groceries at bigbasket", "INR", TODAY) | |
| assert draft is not None | |
| assert "1" not in draft.description | |
| assert "₹" not in draft.description | |
| assert draft.category == "groceries" | |
| def test_strips_filler_words(self): | |
| draft = parse_expense("spent 500 on dinner", "INR", TODAY) | |
| assert draft is not None | |
| assert draft.description == "dinner" | |
| def test_date_phrase_leaves_the_description(self): | |
| draft = parse_expense("45.50 lunch yesterday", "INR", TODAY) | |
| assert draft is not None | |
| assert draft.occurred_on == date(2026, 8, 14) | |
| assert "yesterday" not in draft.description | |
| assert draft.description == "lunch" | |
| def test_hashtag_overrides_the_keyword_guess(self): | |
| draft = parse_expense("320 lunch #transport", "INR", TODAY) | |
| assert draft is not None | |
| # "lunch" would normally be food_dining; the tag wins. | |
| assert draft.category == "transport" | |
| assert "#" not in draft.description | |
| def test_unknown_hashtag_falls_back_to_other(self): | |
| draft = parse_expense("100 thing #nonsense", "INR", TODAY) | |
| assert draft is not None | |
| assert draft.category == "other" | |
| def test_explicit_currency_beats_the_default(self): | |
| draft = parse_expense("€40 dinner", "INR", TODAY) | |
| assert draft is not None | |
| assert draft.currency == "EUR" | |
| assert draft.amount_minor == 4000 | |
| def test_description_never_empty(self): | |
| draft = parse_expense("250", "INR", TODAY) | |
| assert draft is not None | |
| assert draft.description == "unlabelled" | |
| def test_keeps_the_raw_input(self): | |
| draft = parse_expense(" coffee 250 ", "INR", TODAY) | |
| assert draft is not None | |
| assert draft.raw == "coffee 250" | |
| class TestZeroAmounts: | |
| """Zero is not an expense -- otherwise ordinary sentences get logged.""" | |
| def test_zero_is_rejected(self, text): | |
| assert parse_expense(text, "INR", TODAY) is None | |
| def test_positive_amounts_still_parse(self): | |
| assert parse_expense("coffee 1", "INR", TODAY).amount_minor == 100 | |