File size: 7,179 Bytes
d999bba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
"""Regression tests for the command surface (2026-06-15).

The command surface merges the legacy "Quick add" + "Ask ShopStack"
inputs into a single typed input. The user types one of:

* "add milk"            β†’ add_to_list
* "i bought rice"       β†’ log_purchase
* "we have onion"        β†’ add_stock
* "we finished bread"    β†’ mark_consumed
* anything else         β†’ ask (free-form Ask ShopStack)

These tests pin the intent parser contract so the routes don't
silently change.
"""
from __future__ import annotations

from shopstack.services.command_surface import (
    COMMON_STAPLE_CHIPS,
    CommandIntent,
    parse_intent,
    _normalise_canonical_name,
)


class TestNormaliseCanonicalName:
    """Helper that converts free-text to a canonical slug."""

    def test_simple_lowercase(self):
        assert _normalise_canonical_name("Milk") == "milk"

    def test_multi_word_underscored(self):
        assert _normalise_canonical_name("Wheat Flour") == "wheat_flour"

    def test_punctuation_stripped(self):
        assert _normalise_canonical_name("Milk (1L)") == "milk_1l"

    def test_collapses_whitespace(self):
        assert _normalise_canonical_name("  toor   dal  ") == "toor_dal"

    def test_empty_returns_empty(self):
        assert _normalise_canonical_name("") == ""


class TestParseIntentAddToList:
    def test_simple_add(self):
        intent = parse_intent("add milk")
        assert intent.action == "add_to_list"
        assert intent.canonical_name == "milk"

    def test_need_synonym(self):
        intent = parse_intent("need bread")
        assert intent.action == "add_to_list"
        assert intent.canonical_name == "bread"

    def test_buy_synonym(self):
        intent = parse_intent("buy atta")
        assert intent.action == "add_to_list"
        assert intent.canonical_name == "wheat_flour" or intent.canonical_name == "atta"

    def test_get_synonym(self):
        intent = parse_intent("get eggs")
        assert intent.action == "add_to_list"
        assert intent.canonical_name == "eggs"

    def test_quantity_in_name(self):
        intent = parse_intent("add 2L milk")
        assert intent.action == "add_to_list"
        # The quantity is part of the canonical name; downstream
        # dispatchers can split it later if needed.
        assert "milk" in intent.canonical_name


class TestParseIntentLogPurchase:
    def test_i_bought(self):
        intent = parse_intent("I bought rice")
        assert intent.action == "log_purchase"
        assert intent.canonical_name == "rice"

    def test_just_bought(self):
        intent = parse_intent("just bought bread")
        assert intent.action == "log_purchase"
        assert intent.canonical_name == "bread"

    def test_bought_past_tense(self):
        intent = parse_intent("bought onion")
        assert intent.action == "log_purchase"
        assert intent.canonical_name == "onion"

    def test_purchased_synonym(self):
        intent = parse_intent("purchased cooking_oil")
        assert intent.action == "log_purchase"
        assert intent.canonical_name == "cooking_oil"


class TestParseIntentAddStock:
    def test_we_have(self):
        intent = parse_intent("we have onion")
        assert intent.action == "add_stock"
        assert intent.canonical_name == "onion"

    def test_at_home(self):
        intent = parse_intent("at home 2kg rice")
        assert intent.action == "add_stock"
        assert "rice" in intent.canonical_name

    def test_stocked_synonym(self):
        intent = parse_intent("stocked up on dal")
        assert intent.action == "add_stock"
        assert "dal" in intent.canonical_name


class TestParseIntentMarkConsumed:
    def test_we_finished(self):
        intent = parse_intent("we finished bread")
        assert intent.action == "mark_consumed"
        assert intent.canonical_name == "bread"

    def test_consume_synonym(self):
        intent = parse_intent("consume milk")
        assert intent.action == "mark_consumed"
        assert intent.canonical_name == "milk"

    def test_ate(self):
        intent = parse_intent("ate tomato")
        assert intent.action == "mark_consumed"
        assert intent.canonical_name == "tomato"


class TestParseIntentAskFallback:
    """If nothing matches, fall through to 'ask' (free-form)."""

    def test_question_mark(self):
        intent = parse_intent("do we have milk?")
        assert intent.action == "ask"
        assert intent.canonical_name == ""
        assert intent.raw == "do we have milk?"

    def test_long_sentence(self):
        intent = parse_intent("what did we buy last week?")
        assert intent.action == "ask"
        assert intent.canonical_name == ""

    def test_empty(self):
        intent = parse_intent("")
        assert intent.action == "unknown"
        assert intent.canonical_name == ""

    def test_whitespace_only(self):
        intent = parse_intent("   ")
        assert intent.action == "unknown"
        assert intent.canonical_name == ""


class TestCommonStapleChips:
    """The chip row shows 10 most common Indian household staples."""

    def test_contains_milk(self):
        assert "milk" in COMMON_STAPLE_CHIPS

    def test_contains_eggs(self):
        assert "eggs" in COMMON_STAPLE_CHIPS

    def test_contains_rice(self):
        assert "rice" in COMMON_STAPLE_CHIPS

    def test_size_is_reasonable(self):
        # 10 chips is a balance between useful and not overwhelming
        assert 5 <= len(COMMON_STAPLE_CHIPS) <= 12

    def test_all_canonical_names(self):
        for chip in COMMON_STAPLE_CHIPS:
            assert chip.replace("_", "").isalnum(), f"chip '{chip}' has unexpected chars"


class TestDispatchHandlerContract:
    """The dispatch function delegates to a registered handler.

    These tests use a stub handler (no DB) to verify the routing.
    """

    def test_dispatch_routes_to_action_handler(self):
        from shopstack.services import command_surface as cs

        def add_to_list_handler(canonical_name, *, intent=None):
            return cs.CommandResult(
                success=True,
                action="add_to_list",
                canonical_name=canonical_name,
                message=f"added: {canonical_name}",
            )

        def ask_handler(canonical_name, *, intent=None):
            return cs.CommandResult(
                success=True,
                action="ask",
                canonical_name=canonical_name,
                message=f"asked: {intent.raw if intent else canonical_name}",
            )

        cs.register_handler("add_to_list", add_to_list_handler)
        cs.register_handler("ask", ask_handler)

        # "add milk" β†’ add_to_list handler
        intent = parse_intent("add milk")
        result = cs.dispatch(intent)
        assert result.success
        assert result.action == "add_to_list"
        assert result.canonical_name == "milk"

        # Unmatched text β†’ ask fall-through
        intent = parse_intent("what did we buy last week?")
        result = cs.dispatch(intent)
        assert result.success
        assert result.action == "ask"
        assert intent.raw == "what did we buy last week?"