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 382 lines 14 kB view raw
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