1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217
|
"""Unit tests for the Markdown widget."""
from __future__ import annotations
from pathlib import Path
from typing import Iterator
import pytest
from markdown_it.token import Token
from rich.style import Style
from rich.text import Span
import textual.widgets._markdown as MD
from textual import on
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual.widgets import Markdown
from textual.widgets.markdown import MarkdownBlock
class UnhandledToken(MarkdownBlock):
def __init__(self, markdown: Markdown, token: Token) -> None:
super().__init__(markdown)
self._token = token
def __repr___(self) -> str:
return self._token.type
class FussyMarkdown(Markdown):
def unhandled_token(self, token: Token) -> MarkdownBlock | None:
return UnhandledToken(self, token)
class MarkdownApp(App[None]):
def __init__(self, markdown: str) -> None:
super().__init__()
self._markdown = markdown
def compose(self) -> ComposeResult:
yield FussyMarkdown(self._markdown)
@pytest.mark.parametrize(
["document", "expected_nodes"],
[
# Basic markup.
("", []),
("# Hello", [MD.MarkdownH1]),
("## Hello", [MD.MarkdownH2]),
("### Hello", [MD.MarkdownH3]),
("#### Hello", [MD.MarkdownH4]),
("##### Hello", [MD.MarkdownH5]),
("###### Hello", [MD.MarkdownH6]),
("---", [MD.MarkdownHorizontalRule]),
("Hello", [MD.MarkdownParagraph]),
("Hello\nWorld", [MD.MarkdownParagraph]),
("> Hello", [MD.MarkdownBlockQuote, MD.MarkdownParagraph]),
("- One\n-Two", [MD.MarkdownBulletList, MD.MarkdownParagraph]),
(
"1. One\n2. Two",
[MD.MarkdownOrderedList, MD.MarkdownParagraph, MD.MarkdownParagraph],
),
(" 1", [MD.MarkdownFence]),
("```\n1\n```", [MD.MarkdownFence]),
("```python\n1\n```", [MD.MarkdownFence]),
("""| One | Two |\n| :- | :- |\n| 1 | 2 |""", [MD.MarkdownTable]),
# Test for https://github.com/Textualize/textual/issues/2676
(
"- One\n```\nTwo\n```\n- Three\n",
[
MD.MarkdownBulletList,
MD.MarkdownParagraph,
MD.MarkdownFence,
MD.MarkdownBulletList,
MD.MarkdownParagraph,
],
),
],
)
async def test_markdown_nodes(
document: str, expected_nodes: list[Widget | list[Widget]]
) -> None:
"""A Markdown document should parse into the expected Textual node list."""
def markdown_nodes(root: Widget) -> Iterator[MarkdownBlock]:
for node in root.children:
if isinstance(node, MarkdownBlock):
yield node
yield from markdown_nodes(node)
async with MarkdownApp(document).run_test() as pilot:
assert [
node.__class__ for node in markdown_nodes(pilot.app.query_one(Markdown))
] == expected_nodes
async def test_softbreak_split_links_rendered_correctly() -> None:
"""Test for https://github.com/Textualize/textual/issues/2805"""
document = """\
My site [has
this
URL](https://example.com)\
"""
async with MarkdownApp(document).run_test() as pilot:
markdown = pilot.app.query_one(Markdown)
paragraph = markdown.children[0]
assert isinstance(paragraph, MD.MarkdownParagraph)
assert paragraph._text.plain == "My site has this URL"
expected_spans = [
Span(8, 11, Style(meta={"@click": "link('https://example.com')"})),
Span(11, 12, Style(meta={"@click": "link('https://example.com')"})),
Span(12, 16, Style(meta={"@click": "link('https://example.com')"})),
Span(16, 17, Style(meta={"@click": "link('https://example.com')"})),
Span(17, 20, Style(meta={"@click": "link('https://example.com')"})),
]
assert paragraph._text.spans == expected_spans
async def test_load_non_existing_file() -> None:
"""Loading a file that doesn't exist should result in the obvious error."""
async with MarkdownApp("").run_test() as pilot:
with pytest.raises(FileNotFoundError):
await pilot.app.query_one(Markdown).load(
Path("---this-does-not-exist---.it.is.not.a.md")
)
@pytest.mark.parametrize(
("anchor", "found"),
[
("hello-world", False),
("hello-there", True),
],
)
async def test_goto_anchor(anchor: str, found: bool) -> None:
"""Going to anchors should return a boolean: whether the anchor was found."""
document = "# Hello There\n\nGeneral.\n"
async with MarkdownApp(document).run_test() as pilot:
markdown = pilot.app.query_one(Markdown)
assert markdown.goto_anchor(anchor) is found
async def test_update_of_document_posts_table_of_content_update_message() -> None:
"""Updating the document should post a TableOfContentsUpdated message."""
messages: list[str] = []
class TableOfContentApp(App[None]):
def compose(self) -> ComposeResult:
yield Markdown("# One\n\n#Two\n")
@on(Markdown.TableOfContentsUpdated)
def log_table_of_content_update(
self, event: Markdown.TableOfContentsUpdated
) -> None:
nonlocal messages
messages.append(event.__class__.__name__)
async with TableOfContentApp().run_test() as pilot:
assert messages == ["TableOfContentsUpdated"]
await pilot.app.query_one(Markdown).update("")
await pilot.pause()
assert messages == ["TableOfContentsUpdated", "TableOfContentsUpdated"]
async def test_link_in_markdown_table_posts_message_when_clicked():
"""A link inside a markdown table should post a `Markdown.LinkClicked`
message when clicked.
Regression test for https://github.com/Textualize/textual/issues/4683
"""
markdown_table = """\
| Textual Links |
| ------------------------------------------------ |
| [GitHub](https://github.com/textualize/textual/) |
| [Documentation](https://textual.textualize.io/) |\
"""
class MarkdownTableApp(App):
messages = []
def compose(self) -> ComposeResult:
yield Markdown(markdown_table, open_links=False)
@on(Markdown.LinkClicked)
def log_markdown_link_clicked(
self,
event: Markdown.LinkClicked,
) -> None:
self.messages.append(event.__class__.__name__)
app = MarkdownTableApp()
async with app.run_test() as pilot:
await pilot.click(Markdown, offset=(3, 3))
assert app.messages == ["LinkClicked"]
async def test_markdown_quoting():
# https://github.com/Textualize/textual/issues/3350
links = []
class MyApp(App):
def compose(self) -> ComposeResult:
self.md = Markdown(markdown="[tété](tété)", open_links=False)
yield self.md
def on_markdown_link_clicked(self, message: Markdown.LinkClicked):
links.append(message.href)
app = MyApp()
async with app.run_test() as pilot:
await pilot.click(Markdown, offset=(3, 0))
assert links == ["tété"]
|