A 5e storytelling engine with an LLM DM
1"""Tests for the streaming markdown renderer."""
2
3import io
4
5import pytest
6from rich.console import Console
7
8from storied.display import StreamRenderer
9
10BOLD_ON = "\033[1m"
11BOLD_OFF = "\033[22m"
12ITALIC_ON = "\033[3m"
13ITALIC_OFF = "\033[23m"
14CODE_ON = "\033[1;33m"
15CODE_OFF = "\033[22;39m"
16DIM_ON = "\033[2m"
17
18
19@pytest.fixture
20def out() -> io.StringIO:
21 return io.StringIO()
22
23
24@pytest.fixture
25def renderer(out: io.StringIO) -> StreamRenderer:
26 console = Console(file=out, force_terminal=True, no_color=False)
27 return StreamRenderer(console)
28
29
30def rendered(renderer: StreamRenderer, out: io.StringIO) -> str:
31 """Flush and return all output."""
32 renderer.flush()
33 return out.getvalue()
34
35
36# ── Inline formatting ────────────────────────────────────────────────────
37
38
39class TestBold:
40 def test_bold_wraps_text(self, renderer: StreamRenderer, out: io.StringIO):
41 renderer.feed("**hello**\n")
42 text = rendered(renderer, out)
43 assert BOLD_ON in text
44 assert "hello" in text
45 assert BOLD_OFF in text
46
47 def test_bold_across_chunks(self, renderer: StreamRenderer, out: io.StringIO):
48 renderer.feed("**hel")
49 renderer.feed("lo**\n")
50 text = rendered(renderer, out)
51 assert BOLD_ON in text
52 assert BOLD_OFF in text
53
54 def test_star_at_chunk_boundary(self, renderer: StreamRenderer, out: io.StringIO):
55 renderer.feed("*")
56 renderer.feed("*bold**\n")
57 text = rendered(renderer, out)
58 assert BOLD_ON in text
59 assert BOLD_OFF in text
60
61
62class TestItalic:
63 def test_italic_wraps_text(self, renderer: StreamRenderer, out: io.StringIO):
64 renderer.feed("*hello*\n")
65 text = rendered(renderer, out)
66 assert ITALIC_ON in text
67 assert "hello" in text
68 assert ITALIC_OFF in text
69
70 def test_italic_across_chunks(self, renderer: StreamRenderer, out: io.StringIO):
71 renderer.feed("*hel")
72 renderer.feed("lo*\n")
73 text = rendered(renderer, out)
74 assert ITALIC_ON in text
75 assert ITALIC_OFF in text
76
77
78class TestBoldItalic:
79 def test_bold_italic(self, renderer: StreamRenderer, out: io.StringIO):
80 renderer.feed("***both***\n")
81 text = rendered(renderer, out)
82 assert BOLD_ON in text
83 assert ITALIC_ON in text
84 assert "both" in text
85
86 def test_nested_bold_in_italic(self, renderer: StreamRenderer, out: io.StringIO):
87 renderer.feed("*italic **bold** text*\n")
88 text = rendered(renderer, out)
89 assert ITALIC_ON in text
90 assert BOLD_ON in text
91 assert BOLD_OFF in text
92 assert ITALIC_OFF in text
93
94
95class TestCode:
96 def test_inline_code(self, renderer: StreamRenderer, out: io.StringIO):
97 renderer.feed("`code`\n")
98 text = rendered(renderer, out)
99 assert CODE_ON in text
100 assert "code" in text
101 assert CODE_OFF in text
102
103
104class TestPlainText:
105 def test_plain_text_passes_through(
106 self, renderer: StreamRenderer, out: io.StringIO
107 ):
108 renderer.feed("hello world\n")
109 text = rendered(renderer, out)
110 assert "hello world" in text
111
112 def test_multiple_chunks(self, renderer: StreamRenderer, out: io.StringIO):
113 renderer.feed("hello ")
114 renderer.feed("world\n")
115 text = rendered(renderer, out)
116 assert "hello world" in text
117
118 def test_empty_line(self, renderer: StreamRenderer, out: io.StringIO):
119 renderer.feed("before\n\nafter\n")
120 text = rendered(renderer, out)
121 assert "before\n\nafter" in text
122
123
124# ── Line-level constructs ────────────────────────────────────────────────
125
126
127class TestHorizontalRule:
128 def test_rule(self, renderer: StreamRenderer, out: io.StringIO):
129 renderer.feed("---\n")
130 text = rendered(renderer, out)
131 # Rule is rendered by Rich, check it's not literal ---
132 assert "---" not in text or "─" in text or "━" in text or text.strip() != "---"
133
134
135class TestHeadings:
136 def test_h1(self, renderer: StreamRenderer, out: io.StringIO):
137 renderer.feed("# Title\n")
138 text = rendered(renderer, out)
139 assert BOLD_ON in text
140 assert "Title" in text
141
142 def test_h2(self, renderer: StreamRenderer, out: io.StringIO):
143 renderer.feed("## Section\n")
144 text = rendered(renderer, out)
145 assert BOLD_ON in text
146 assert "Section" in text
147
148 def test_h3(self, renderer: StreamRenderer, out: io.StringIO):
149 renderer.feed("### Subsection\n")
150 text = rendered(renderer, out)
151 assert BOLD_ON in text
152 assert "Subsection" in text
153
154 def test_heading_with_inline_bold(self, renderer: StreamRenderer, out: io.StringIO):
155 renderer.feed("### **Vera's** Secret\n")
156 text = rendered(renderer, out)
157 assert "Vera's" in text
158 assert "Secret" in text
159
160
161class TestBulletList:
162 def test_dash_bullet(self, renderer: StreamRenderer, out: io.StringIO):
163 renderer.feed("- first item\n")
164 text = rendered(renderer, out)
165 assert "•" in text
166 assert "first item" in text
167
168 def test_star_bullet(self, renderer: StreamRenderer, out: io.StringIO):
169 renderer.feed("* second item\n")
170 text = rendered(renderer, out)
171 assert "•" in text
172 assert "second item" in text
173
174
175class TestBlockquote:
176 def test_blockquote(self, renderer: StreamRenderer, out: io.StringIO):
177 renderer.feed("> quoted text\n")
178 text = rendered(renderer, out)
179 assert "quoted text" in text
180 assert DIM_ON in text
181
182 def test_narrow_block_centered(self, renderer: StreamRenderer, out: io.StringIO):
183 renderer.feed("```aside Note\nShort\n```\n")
184 text = rendered(renderer, out)
185 assert "Note" in text
186
187
188class TestNumberedList:
189 def test_numbered(self, renderer: StreamRenderer, out: io.StringIO):
190 renderer.feed("1. first\n")
191 text = rendered(renderer, out)
192 assert "1." in text
193 assert "first" in text
194
195
196# ── Display blocks ───────────────────────────────────────────────────────
197
198
199class TestBlocks:
200 def test_map_block_renders_panel(self, renderer: StreamRenderer, out: io.StringIO):
201 renderer.feed("```map Tavern\n+-+\n|X|\n+-+\n```\n")
202 text = rendered(renderer, out)
203 assert "Tavern" in text
204 assert "+-+" in text
205
206 def test_aside_block(self, renderer: StreamRenderer, out: io.StringIO):
207 renderer.feed("```aside A Letter\nDear friend,\n```\n")
208 text = rendered(renderer, out)
209 assert "A Letter" in text
210 assert "Dear friend," in text
211
212 def test_block_between_text(self, renderer: StreamRenderer, out: io.StringIO):
213 renderer.feed("Before\n```map Room\n+-+\n```\nAfter\n")
214 text = rendered(renderer, out)
215 assert "Before" in text
216 assert "+-+" in text
217 assert "After" in text
218
219 def test_regular_code_block_passes_through(
220 self, renderer: StreamRenderer, out: io.StringIO
221 ):
222 renderer.feed("```python\nprint('hi')\n```\n")
223 text = rendered(renderer, out)
224 assert "python" in text
225 assert "print" in text
226
227 def test_block_after_flush(self, renderer: StreamRenderer, out: io.StringIO):
228 renderer.feed("Some text\n")
229 renderer.flush()
230 renderer.feed("```item Dagger\n/|\\\n```\n")
231 text = rendered(renderer, out)
232 assert "Dagger" in text
233 assert "/|\\" in text
234
235
236# ── SOL edge cases ───────────────────────────────────────────────────────
237
238
239class TestSOLClassification:
240 def test_text_starting_with_hash_no_space(
241 self, renderer: StreamRenderer, out: io.StringIO
242 ):
243 renderer.feed("#hashtag\n")
244 text = rendered(renderer, out)
245 assert "#hashtag" in text
246 assert BOLD_ON not in text
247
248 def test_text_starting_with_dash_no_space(
249 self, renderer: StreamRenderer, out: io.StringIO
250 ):
251 renderer.feed("-not a bullet\n")
252 text = rendered(renderer, out)
253 assert "-not a bullet" in text
254
255 def test_dashes_not_a_rule(self, renderer: StreamRenderer, out: io.StringIO):
256 renderer.feed("--not a rule\n")
257 text = rendered(renderer, out)
258 assert "--not a rule" in text
259
260 def test_line_starting_with_letter(
261 self, renderer: StreamRenderer, out: io.StringIO
262 ):
263 renderer.feed("The quick brown fox\n")
264 text = rendered(renderer, out)
265 assert "The quick brown fox" in text
266
267
268# ── Flush ────────────────────────────────────────────────────────────────
269
270
271class TestFlush:
272 def test_flush_emits_buffered_star_as_italic(
273 self, renderer: StreamRenderer, out: io.StringIO
274 ):
275 renderer.feed("trailing*")
276 text = rendered(renderer, out)
277 assert "trailing" in text
278 assert ITALIC_ON in text
279
280 def test_flush_resets_bold(self, renderer: StreamRenderer, out: io.StringIO):
281 renderer.feed("**unclosed bold")
282 text = rendered(renderer, out)
283 assert "\033[0m" in text
284
285 def test_flush_emits_sol_buffer(self, renderer: StreamRenderer, out: io.StringIO):
286 renderer.feed("##") # SOL buffer, waiting for more
287 text = rendered(renderer, out)
288 assert "##" in text
289
290
291# ── Word wrapping ───────────────────────────────────────────────────────
292
293
294@pytest.fixture
295def narrow_renderer(out: io.StringIO) -> StreamRenderer:
296 """Renderer with a narrow terminal width for testing word wrap."""
297 console = Console(file=out, force_terminal=True, no_color=False, width=20)
298 return StreamRenderer(console)
299
300
301class TestWordWrap:
302 def test_short_line_no_wrap(
303 self, narrow_renderer: StreamRenderer, out: io.StringIO
304 ):
305 narrow_renderer.feed("hello world\n")
306 text = rendered(narrow_renderer, out)
307 assert "hello world" in text
308 assert text.count("\n") == 1 # just the trailing newline
309
310 def test_wraps_at_word_boundary(
311 self, narrow_renderer: StreamRenderer, out: io.StringIO
312 ):
313 # "one two three four" = 18 chars, fits. Add "five" and it wraps.
314 narrow_renderer.feed("one two three four five\n")
315 text = rendered(narrow_renderer, out)
316 assert "four\n" in text or "four \n" not in text
317 # "five" should be on the next line
318 lines = text.strip().split("\n")
319 assert len(lines) == 2
320 assert "five" in lines[1]
321
322 def test_no_mid_word_break(self, narrow_renderer: StreamRenderer, out: io.StringIO):
323 narrow_renderer.feed("aaa bbb ccccccccccccc ddd\n")
324 text = rendered(narrow_renderer, out)
325 # "ccccccccccccc" is 13 chars, should not be broken
326 for line in text.split("\n"):
327 assert "ccccccc" not in line or "ccccccccccccc" in line
328
329 def test_word_longer_than_width_overflows(
330 self, narrow_renderer: StreamRenderer, out: io.StringIO
331 ):
332 # A single word longer than 20 chars just overflows (no crash)
333 narrow_renderer.feed("superlongwordthatexceedstwentycharacters end\n")
334 text = rendered(narrow_renderer, out)
335 assert "superlongwordthatexceedstwentycharacters" in text
336 assert "end" in text
337
338 def test_wrap_preserves_bold(
339 self, narrow_renderer: StreamRenderer, out: io.StringIO
340 ):
341 narrow_renderer.feed("aaa bbb **ccc ddd eee fff** ggg\n")
342 text = rendered(narrow_renderer, out)
343 assert BOLD_ON in text
344 assert BOLD_OFF in text
345 # All words should be present
346 for word in ("aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg"):
347 assert word in text
348
349 def test_wrap_preserves_italic(
350 self, narrow_renderer: StreamRenderer, out: io.StringIO
351 ):
352 narrow_renderer.feed("aaa bbb *ccc ddd eee fff* ggg\n")
353 text = rendered(narrow_renderer, out)
354 assert ITALIC_ON in text
355 assert ITALIC_OFF in text
356
357 def test_bullet_wrap_accounts_for_prefix(
358 self, narrow_renderer: StreamRenderer, out: io.StringIO
359 ):
360 # " • " = 4 chars, so only 16 chars of content before wrap
361 narrow_renderer.feed("- aaa bbb ccc ddd eee\n")
362 text = rendered(narrow_renderer, out)
363 assert "•" in text
364 lines = text.strip().split("\n")
365 assert len(lines) >= 2 # should wrap
366
367 def test_streaming_chunks_wrap_correctly(
368 self, narrow_renderer: StreamRenderer, out: io.StringIO
369 ):
370 # Words arrive across multiple chunks
371 narrow_renderer.feed("one two thr")
372 narrow_renderer.feed("ee four five ")
373 narrow_renderer.feed("six\n")
374 text = rendered(narrow_renderer, out)
375 lines = text.strip().split("\n")
376 assert len(lines) >= 2
377 # No word should be broken
378 all_words = set()
379 for line in lines:
380 all_words.update(line.split())
381 for word in ("one", "two", "three", "four", "five", "six"):
382 assert word in all_words