A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 285 lines 9.5 kB view raw
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 )