A 5e storytelling engine with an LLM DM
1"""Tests for the deferred-tool notification formatters.
2
3These are pure functions that turn accumulated tool input JSON into a
4human-readable label for the streaming TUI. Each formatter is exercised
5through its real DEFERRED_FORMATTERS lookup, so a missing entry in the
6dispatch table would surface here too.
7"""
8
9import pytest
10
11from storied.notification_formatters import (
12 DEFERRED_FORMATTERS,
13 TOOL_LABELS,
14 _extract_json_field,
15 _extract_roll_reason,
16 _parse_tool_args,
17)
18
19# --- Low-level helpers ------------------------------------------------------
20
21
22class TestParseToolArgs:
23 def test_valid_json(self):
24 assert _parse_tool_args('{"a": 1}') == {"a": 1}
25
26 def test_empty_string(self):
27 assert _parse_tool_args("") == {}
28
29 def test_malformed_json(self):
30 assert _parse_tool_args("{not valid}") == {}
31
32 def test_null_json_returns_empty(self):
33 # json.loads("null") returns None, the helper coerces to {}
34 assert _parse_tool_args("null") == {}
35
36
37class TestExtractJsonField:
38 def test_field_present(self):
39 assert _extract_json_field('{"reason": "attack"}', "reason") == "attack"
40
41 def test_field_missing(self):
42 assert _extract_json_field('{"other": 1}', "reason") is None
43
44 def test_malformed_returns_none(self):
45 assert _extract_json_field("{bad}", "reason") is None
46
47 def test_extract_roll_reason(self):
48 assert _extract_roll_reason('{"reason": "Stealth"}') == "Stealth"
49
50 def test_extract_roll_reason_missing(self):
51 assert _extract_roll_reason('{"notation": "1d20"}') is None
52
53
54# --- Per-tool formatters ----------------------------------------------------
55
56
57def _format(tool: str, tool_json: str) -> str:
58 """Look up the formatter via DEFERRED_FORMATTERS and call it."""
59 return DEFERRED_FORMATTERS[tool](tool_json)
60
61
62class TestCoinNotification:
63 def test_spend_one_denomination(self):
64 result = _format("adjust_coins", '{"deltas": {"gp": -5}}')
65 assert result == "Spending 5 gold"
66
67 def test_receive_one_denomination(self):
68 result = _format("adjust_coins", '{"deltas": {"gp": 10}}')
69 assert result == "Receiving 10 gold"
70
71 def test_spend_and_receive(self):
72 result = _format("adjust_coins", '{"deltas": {"gp": -5, "sp": 3}}')
73 assert "Spending 5 gold" in result
74 assert "receiving 3 silver" in result
75
76 def test_multiple_denominations_in_order(self):
77 result = _format("adjust_coins", '{"deltas": {"pp": 1, "gp": 2, "cp": 7}}')
78 # Output orders pp → gp → ep → sp → cp
79 assert result == "Receiving 1 platinum, 2 gold, 7 copper"
80
81 def test_zero_delta_omitted(self):
82 result = _format("adjust_coins", '{"deltas": {"gp": -5, "sp": 0}}')
83 assert "silver" not in result
84 assert "Spending 5 gold" in result
85
86 def test_no_deltas_at_all(self):
87 result = _format("adjust_coins", '{"deltas": {}}')
88 assert result == "Adjusting coins"
89
90 def test_unknown_denomination_falls_back_to_code(self):
91 # Defensive: unknown denom uses the raw key
92 from storied.notification_formatters import _format_coin_notification
93
94 result = _format_coin_notification('{"deltas": {"xx": 5}}')
95 # _DENOM_NAMES doesn't include 'xx', so it falls through the loop
96 # without matching any of the known denoms — output is generic
97 assert result == "Adjusting coins"
98
99
100class TestDamageNotification:
101 def test_with_target(self):
102 assert (
103 _format("damage", '{"target": "Goblin", "amount": 7}')
104 == "Goblin takes 7 damage"
105 )
106
107 def test_with_type(self):
108 assert (
109 _format("damage", '{"amount": 5, "type": "fire"}') == "Taking 5 fire damage"
110 )
111
112 def test_plain(self):
113 assert _format("damage", '{"amount": 3}') == "Taking 3 damage"
114
115 def test_target_and_type_target_wins(self):
116 result = _format("damage", '{"target": "Mira", "amount": 5, "type": "cold"}')
117 assert result == "Mira takes 5 damage"
118
119 def test_missing_amount_shows_question_mark(self):
120 assert _format("damage", "{}") == "Taking ? damage"
121
122
123class TestHealNotification:
124 def test_with_target(self):
125 assert (
126 _format("heal", '{"target": "Mira", "amount": 5}') == "Healing Mira for 5"
127 )
128
129 def test_plain(self):
130 assert _format("heal", '{"amount": 8}') == "Healing 8 HP"
131
132
133class TestEffectNotification:
134 def test_simple(self):
135 result = _format("add_effect", '{"source": "Bless", "description": "+1d4"}')
136 assert result == "Adding effect: Bless"
137
138 def test_with_expires(self):
139 result = _format(
140 "add_effect",
141 '{"source": "Heroism", "description": "+10 temp HP",'
142 ' "expires": "d28-1430"}',
143 )
144 assert "Heroism" in result
145 assert "until d28-1430" in result
146
147 def test_remove_effect(self):
148 assert (
149 _format("remove_effect", '{"source": "Bless"}') == "Removing effect: Bless"
150 )
151
152
153class TestConditionNotification:
154 def test_add_condition(self):
155 assert _format("add_condition", '{"name": "Poisoned"}') == "Becoming Poisoned"
156
157 def test_remove_condition(self):
158 assert (
159 _format("remove_condition", '{"name": "Frightened"}')
160 == "Recovering from Frightened"
161 )
162
163
164class TestItemNotification:
165 def test_add_item_no_location(self):
166 assert _format("add_item", '{"item": "Lockpicks"}') == "Picking up 'Lockpicks'"
167
168 def test_add_item_with_location(self):
169 result = _format("add_item", '{"item": "Coin pouch", "location": "on_person"}')
170 assert result == "Adding 'Coin pouch' to on_person"
171
172 def test_remove_item(self):
173 assert (
174 _format("remove_item", '{"item": "Boot knife"}') == "Removing 'Boot knife'"
175 )
176
177 @pytest.mark.parametrize(
178 ("status", "verb"),
179 [
180 ("attuned", "Attuning to"),
181 ("equipped", "Equipping"),
182 ("carried", "Stowing"),
183 ("unknown_status", "Setting status of"),
184 ],
185 )
186 def test_set_item_status(self, status: str, verb: str):
187 result = _format(
188 "set_item_status",
189 f'{{"item": "Bracer", "status": "{status}"}}',
190 )
191 assert result == f"{verb} Bracer"
192
193
194class TestResourceNotification:
195 def test_use_one(self):
196 assert (
197 _format("adjust_resource", '{"name": "rage", "delta": -1}') == "Using rage"
198 )
199
200 def test_use_multiple(self):
201 assert (
202 _format("adjust_resource", '{"name": "ki", "delta": -3}') == "Using 3 of ki"
203 )
204
205 def test_restore(self):
206 assert (
207 _format("adjust_resource", '{"name": "ki", "delta": 2}')
208 == "Restoring 2 of ki"
209 )
210
211 def test_zero_delta_fallback(self):
212 assert (
213 _format("adjust_resource", '{"name": "rage", "delta": 0}')
214 == "Adjusting rage"
215 )
216
217
218class TestRestNotification:
219 def test_short(self):
220 assert _format("rest", '{"type": "short"}') == "Taking a short rest"
221
222 def test_long(self):
223 assert _format("rest", '{"type": "long"}') == "Taking a long rest"
224
225 def test_default_short(self):
226 assert _format("rest", "{}") == "Taking a short rest"
227
228
229class TestNoteNotification:
230 def test_short_text(self):
231 assert _format("add_note", '{"text": "Found a key"}') == "Noting: Found a key"
232
233 def test_long_text_truncated(self):
234 text = "A" * 80
235 result = _format("add_note", f'{{"text": "{text}"}}')
236 assert result.endswith("...")
237 assert len(result) < len(text) + 20
238
239
240class TestUpdateCharacterNotification:
241 def test_no_updates(self):
242 assert _format("update_character", '{"updates": {}}') == "Updating character"
243
244 def test_one_key(self):
245 result = _format("update_character", '{"updates": {"state.ac": 17}}')
246 assert result == "Updating: state.ac"
247
248 def test_three_keys(self):
249 result = _format(
250 "update_character",
251 '{"updates": {"state.ac": 17, "state.speed": 30, "level": 4}}',
252 )
253 assert "state.ac" in result
254 assert "state.speed" in result
255 assert "level" in result
256 assert "..." not in result
257
258 def test_more_than_three_keys_truncates(self):
259 result = _format(
260 "update_character",
261 '{"updates": {"a": 1, "b": 2, "c": 3, "d": 4}}',
262 )
263 assert result.endswith("...")
264
265
266# --- Dispatch table sanity --------------------------------------------------
267
268
269class TestFormatterDispatchTable:
270 def test_all_formatters_handle_empty_input(self):
271 """Every registered formatter must produce a non-empty string for
272 empty / malformed input rather than crashing."""
273 for tool_name, formatter in DEFERRED_FORMATTERS.items():
274 result = formatter("")
275 assert isinstance(result, str)
276 assert result, f"{tool_name} produced empty label for empty input"
277
278 def test_every_deferred_tool_has_a_label(self):
279 """TOOL_LABELS is the fallback when there's no formatter; every
280 formatter-keyed tool should also have a label entry so the renderer
281 can fall back gracefully."""
282 for tool_name in DEFERRED_FORMATTERS:
283 assert tool_name in TOOL_LABELS, (
284 f"deferred tool {tool_name} has no entry in TOOL_LABELS"
285 )