Spaces:
Running on Zero
Running on Zero
File size: 5,067 Bytes
bfd6783 260fe4e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | 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
|