A 5e storytelling engine with an LLM DM
1"""Tests for sandboxed code execution via Monty."""
2
3from storied.sandbox import build_tool_signatures, execute
4from storied.tools import ToolContext
5
6
7class TestBasicExecution:
8 """Core execution: expressions, statements, print output."""
9
10 def test_simple_expression(self):
11 result = execute("1 + 2")
12
13 assert "3" in result
14
15 def test_multi_line_script(self):
16 result = execute("x = 10\ny = 20\nx + y")
17
18 assert "30" in result
19
20 def test_print_captured(self):
21 result = execute('print("hello")\nprint("world")')
22
23 assert "hello" in result
24 assert "world" in result
25
26 def test_print_and_expression(self):
27 result = execute('print("debug")\n42')
28
29 assert "debug" in result
30 assert "42" in result
31
32 def test_no_return_value(self):
33 result = execute("x = 5")
34
35 assert isinstance(result, str)
36
37 def test_empty_code(self):
38 result = execute("")
39
40 assert isinstance(result, str)
41
42
43class TestHostFunctions:
44 """Host functions exposed to sandboxed code."""
45
46 def test_roll_available(self):
47 result = execute('r = roll("1d6")\nr["total"]')
48
49 assert result # should contain a number 1-6
50
51 def test_roll_returns_dict(self):
52 result = execute("r = roll('2d6+3')\nprint('total=' + str(r['total']))")
53
54 assert "total=" in result
55
56 def test_roll_with_reason_positional(self):
57 result = execute('r = roll("1d6", "picking a name")\nr["total"]')
58
59 assert result
60
61 def test_roll_with_reason_keyword(self):
62 result = execute('r = roll(notation="1d6", reason="picking")\nr["total"]')
63
64 assert result
65
66 def test_custom_host_function(self):
67 result = execute(
68 "double(5)",
69 host_functions={"double": lambda x: x * 2},
70 )
71
72 assert "10" in result
73
74
75class TestErrorHandling:
76 """Errors returned as strings, not raised."""
77
78 def test_division_by_zero(self):
79 result = execute("1 / 0")
80
81 assert "ZeroDivisionError" in result
82
83 def test_name_error(self):
84 result = execute("undefined_variable")
85
86 assert "NameError" in result
87
88 def test_syntax_error(self):
89 result = execute("if True")
90
91 assert result # Monty returns a parse error message
92
93 def test_type_error(self):
94 result = execute('"hello" + 5')
95
96 assert "TypeError" in result
97
98
99class TestResourceLimits:
100 """Sandbox enforces time and memory limits."""
101
102 def test_infinite_loop_times_out(self):
103 result = execute("while True: pass")
104
105 assert "time" in result.lower() or "timeout" in result.lower()
106
107
108class TestSandboxSecurity:
109 """Dangerous operations are blocked."""
110
111 def test_no_file_access(self):
112 result = execute('open("/etc/passwd").read()')
113
114 assert "Error" in result
115
116 def test_no_os_access(self):
117 result = execute("import os\nos.environ")
118
119 assert "NotImplementedError" in result
120
121
122class TestToolBridge:
123 """DM tools exposed as direct host functions.
124
125 The sandbox resolves Dependency parameters from the process-global
126 ToolContext (set up by the `ctx` fixture), so callers don't have to
127 pass `ctx` explicitly anymore.
128 """
129
130 def test_recall(self, ctx: ToolContext):
131 result = execute("recall(query='nonexistent')")
132
133 assert "Nothing found" in result
134
135 def test_establish(self, ctx: ToolContext):
136 result = execute(
137 "establish(entity_type='npc', name='Bob the Baker', "
138 "description='A friendly baker')"
139 )
140
141 assert "Bob" in result
142
143
144class TestToolSignatures:
145 """Dynamic signature builder for the run_code tool description."""
146
147 def test_includes_all_tools(self):
148 sigs = build_tool_signatures()
149
150 for name in [
151 "roll",
152 "recall",
153 "establish",
154 "mark",
155 "damage",
156 "heal",
157 "enter_initiative",
158 "end_initiative",
159 "next_turn",
160 ]:
161 assert f"{name}(" in sigs
162
163 def test_excludes_dependency_params(self):
164 """Dependency-default params are wrapper bookkeeping; the LLM-facing
165 signature should drop them all."""
166 sigs = build_tool_signatures()
167
168 # None of the Dependency-class instances should appear as defaults
169 for marker in (
170 "Combat()",
171 "Lore()",
172 "Player()",
173 "Timekeeper()",
174 "Entities()",
175 "World()",
176 ):
177 assert marker not in sigs