"""Tests for the streaming markdown renderer.""" import io import pytest from rich.console import Console from storied.display import StreamRenderer BOLD_ON = "\033[1m" BOLD_OFF = "\033[22m" ITALIC_ON = "\033[3m" ITALIC_OFF = "\033[23m" CODE_ON = "\033[1;33m" CODE_OFF = "\033[22;39m" DIM_ON = "\033[2m" @pytest.fixture def out() -> io.StringIO: return io.StringIO() @pytest.fixture def renderer(out: io.StringIO) -> StreamRenderer: console = Console(file=out, force_terminal=True, no_color=False) return StreamRenderer(console) def rendered(renderer: StreamRenderer, out: io.StringIO) -> str: """Flush and return all output.""" renderer.flush() return out.getvalue() # ── Inline formatting ──────────────────────────────────────────────────── class TestBold: def test_bold_wraps_text(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("**hello**\n") text = rendered(renderer, out) assert BOLD_ON in text assert "hello" in text assert BOLD_OFF in text def test_bold_across_chunks(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("**hel") renderer.feed("lo**\n") text = rendered(renderer, out) assert BOLD_ON in text assert BOLD_OFF in text def test_star_at_chunk_boundary(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("*") renderer.feed("*bold**\n") text = rendered(renderer, out) assert BOLD_ON in text assert BOLD_OFF in text class TestItalic: def test_italic_wraps_text(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("*hello*\n") text = rendered(renderer, out) assert ITALIC_ON in text assert "hello" in text assert ITALIC_OFF in text def test_italic_across_chunks(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("*hel") renderer.feed("lo*\n") text = rendered(renderer, out) assert ITALIC_ON in text assert ITALIC_OFF in text class TestBoldItalic: def test_bold_italic(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("***both***\n") text = rendered(renderer, out) assert BOLD_ON in text assert ITALIC_ON in text assert "both" in text def test_nested_bold_in_italic(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("*italic **bold** text*\n") text = rendered(renderer, out) assert ITALIC_ON in text assert BOLD_ON in text assert BOLD_OFF in text assert ITALIC_OFF in text class TestCode: def test_inline_code(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("`code`\n") text = rendered(renderer, out) assert CODE_ON in text assert "code" in text assert CODE_OFF in text class TestPlainText: def test_plain_text_passes_through( self, renderer: StreamRenderer, out: io.StringIO ): renderer.feed("hello world\n") text = rendered(renderer, out) assert "hello world" in text def test_multiple_chunks(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("hello ") renderer.feed("world\n") text = rendered(renderer, out) assert "hello world" in text def test_empty_line(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("before\n\nafter\n") text = rendered(renderer, out) assert "before\n\nafter" in text # ── Line-level constructs ──────────────────────────────────────────────── class TestHorizontalRule: def test_rule(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("---\n") text = rendered(renderer, out) # Rule is rendered by Rich, check it's not literal --- assert "---" not in text or "─" in text or "━" in text or text.strip() != "---" class TestHeadings: def test_h1(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("# Title\n") text = rendered(renderer, out) assert BOLD_ON in text assert "Title" in text def test_h2(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("## Section\n") text = rendered(renderer, out) assert BOLD_ON in text assert "Section" in text def test_h3(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("### Subsection\n") text = rendered(renderer, out) assert BOLD_ON in text assert "Subsection" in text def test_heading_with_inline_bold(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("### **Vera's** Secret\n") text = rendered(renderer, out) assert "Vera's" in text assert "Secret" in text class TestBulletList: def test_dash_bullet(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("- first item\n") text = rendered(renderer, out) assert "•" in text assert "first item" in text def test_star_bullet(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("* second item\n") text = rendered(renderer, out) assert "•" in text assert "second item" in text class TestBlockquote: def test_blockquote(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("> quoted text\n") text = rendered(renderer, out) assert "quoted text" in text assert DIM_ON in text def test_narrow_block_centered(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("```aside Note\nShort\n```\n") text = rendered(renderer, out) assert "Note" in text class TestNumberedList: def test_numbered(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("1. first\n") text = rendered(renderer, out) assert "1." in text assert "first" in text # ── Display blocks ─────────────────────────────────────────────────────── class TestBlocks: def test_map_block_renders_panel(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("```map Tavern\n+-+\n|X|\n+-+\n```\n") text = rendered(renderer, out) assert "Tavern" in text assert "+-+" in text def test_aside_block(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("```aside A Letter\nDear friend,\n```\n") text = rendered(renderer, out) assert "A Letter" in text assert "Dear friend," in text def test_block_between_text(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("Before\n```map Room\n+-+\n```\nAfter\n") text = rendered(renderer, out) assert "Before" in text assert "+-+" in text assert "After" in text def test_regular_code_block_passes_through( self, renderer: StreamRenderer, out: io.StringIO ): renderer.feed("```python\nprint('hi')\n```\n") text = rendered(renderer, out) assert "python" in text assert "print" in text def test_block_after_flush(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("Some text\n") renderer.flush() renderer.feed("```item Dagger\n/|\\\n```\n") text = rendered(renderer, out) assert "Dagger" in text assert "/|\\" in text # ── SOL edge cases ─────────────────────────────────────────────────────── class TestSOLClassification: def test_text_starting_with_hash_no_space( self, renderer: StreamRenderer, out: io.StringIO ): renderer.feed("#hashtag\n") text = rendered(renderer, out) assert "#hashtag" in text assert BOLD_ON not in text def test_text_starting_with_dash_no_space( self, renderer: StreamRenderer, out: io.StringIO ): renderer.feed("-not a bullet\n") text = rendered(renderer, out) assert "-not a bullet" in text def test_dashes_not_a_rule(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("--not a rule\n") text = rendered(renderer, out) assert "--not a rule" in text def test_line_starting_with_letter( self, renderer: StreamRenderer, out: io.StringIO ): renderer.feed("The quick brown fox\n") text = rendered(renderer, out) assert "The quick brown fox" in text # ── Flush ──────────────────────────────────────────────────────────────── class TestFlush: def test_flush_emits_buffered_star_as_italic( self, renderer: StreamRenderer, out: io.StringIO ): renderer.feed("trailing*") text = rendered(renderer, out) assert "trailing" in text assert ITALIC_ON in text def test_flush_resets_bold(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("**unclosed bold") text = rendered(renderer, out) assert "\033[0m" in text def test_flush_emits_sol_buffer(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("##") # SOL buffer, waiting for more text = rendered(renderer, out) assert "##" in text # ── Word wrapping ─────────────────────────────────────────────────────── @pytest.fixture def narrow_renderer(out: io.StringIO) -> StreamRenderer: """Renderer with a narrow terminal width for testing word wrap.""" console = Console(file=out, force_terminal=True, no_color=False, width=20) return StreamRenderer(console) class TestWordWrap: def test_short_line_no_wrap( self, narrow_renderer: StreamRenderer, out: io.StringIO ): narrow_renderer.feed("hello world\n") text = rendered(narrow_renderer, out) assert "hello world" in text assert text.count("\n") == 1 # just the trailing newline def test_wraps_at_word_boundary( self, narrow_renderer: StreamRenderer, out: io.StringIO ): # "one two three four" = 18 chars, fits. Add "five" and it wraps. narrow_renderer.feed("one two three four five\n") text = rendered(narrow_renderer, out) assert "four\n" in text or "four \n" not in text # "five" should be on the next line lines = text.strip().split("\n") assert len(lines) == 2 assert "five" in lines[1] def test_no_mid_word_break(self, narrow_renderer: StreamRenderer, out: io.StringIO): narrow_renderer.feed("aaa bbb ccccccccccccc ddd\n") text = rendered(narrow_renderer, out) # "ccccccccccccc" is 13 chars, should not be broken for line in text.split("\n"): assert "ccccccc" not in line or "ccccccccccccc" in line def test_word_longer_than_width_overflows( self, narrow_renderer: StreamRenderer, out: io.StringIO ): # A single word longer than 20 chars just overflows (no crash) narrow_renderer.feed("superlongwordthatexceedstwentycharacters end\n") text = rendered(narrow_renderer, out) assert "superlongwordthatexceedstwentycharacters" in text assert "end" in text def test_wrap_preserves_bold( self, narrow_renderer: StreamRenderer, out: io.StringIO ): narrow_renderer.feed("aaa bbb **ccc ddd eee fff** ggg\n") text = rendered(narrow_renderer, out) assert BOLD_ON in text assert BOLD_OFF in text # All words should be present for word in ("aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg"): assert word in text def test_wrap_preserves_italic( self, narrow_renderer: StreamRenderer, out: io.StringIO ): narrow_renderer.feed("aaa bbb *ccc ddd eee fff* ggg\n") text = rendered(narrow_renderer, out) assert ITALIC_ON in text assert ITALIC_OFF in text def test_bullet_wrap_accounts_for_prefix( self, narrow_renderer: StreamRenderer, out: io.StringIO ): # " • " = 4 chars, so only 16 chars of content before wrap narrow_renderer.feed("- aaa bbb ccc ddd eee\n") text = rendered(narrow_renderer, out) assert "•" in text lines = text.strip().split("\n") assert len(lines) >= 2 # should wrap def test_streaming_chunks_wrap_correctly( self, narrow_renderer: StreamRenderer, out: io.StringIO ): # Words arrive across multiple chunks narrow_renderer.feed("one two thr") narrow_renderer.feed("ee four five ") narrow_renderer.feed("six\n") text = rendered(narrow_renderer, out) lines = text.strip().split("\n") assert len(lines) >= 2 # No word should be broken all_words = set() for line in lines: all_words.update(line.split()) for word in ("one", "two", "three", "four", "five", "six"): assert word in all_words