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