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 @pytest.mark.parametrize( "text,expected", [ ("lunch yesterday", date(2026, 8, 14)), ("coffee today", TODAY), ("dinner day before yesterday", date(2026, 8, 13)), ("paid 3 days ago", date(2026, 8, 12)), ("rent on 2026-08-01", date(2026, 8, 1)), ("bought 12 aug", date(2026, 8, 12)), ("bought aug 12", date(2026, 8, 12)), ], ) 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.""" @pytest.mark.parametrize("text", ["question 0", "0", "round 0 of talks", "0.00"]) 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