-
-
Notifications
You must be signed in to change notification settings - Fork 28
Expand file tree
/
Copy pathtest_footnotes.py
More file actions
271 lines (229 loc) · 11.1 KB
/
Copy pathtest_footnotes.py
File metadata and controls
271 lines (229 loc) · 11.1 KB
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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
"""Tests for Markdown footnote support in post.py."""
from substack.post import Post
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_post():
"""Create a fresh Post instance for testing."""
return Post(title="Test", subtitle="Sub", user_id=1)
def body_content(post):
"""Return the content list from the post's draft body."""
return post.draft_body["content"]
def find_nodes(node, node_type, acc=None):
"""Recursively collect every node of a given type from a doc tree."""
if acc is None:
acc = []
if isinstance(node, dict):
if node.get("type") == node_type:
acc.append(node)
for value in node.values():
find_nodes(value, node_type, acc)
elif isinstance(node, list):
for value in node:
find_nodes(value, node_type, acc)
return acc
def anchors(post):
return find_nodes(post.draft_body, "footnoteAnchor")
def footnotes(post):
return find_nodes(post.draft_body, "footnote")
# ---------------------------------------------------------------------------
# TestFootnoteHelpers
# ---------------------------------------------------------------------------
class TestFootnoteHelpers:
def test_footnote_anchor_added_inline(self):
post = make_post()
post.paragraph(content=[{"content": "See here."}])
post.footnote_anchor(1)
para = body_content(post)[0]
assert para["content"][-1] == {"type": "footnoteAnchor", "attrs": {"number": 1}}
def test_footnote_block_from_string(self):
post = make_post()
post.footnote(1, "A simple note.")
block = body_content(post)[-1]
assert block["type"] == "footnote"
assert block["attrs"] == {"number": 1}
assert block["content"][0]["type"] == "paragraph"
assert block["content"][0]["content"][0]["text"] == "A simple note."
def test_footnote_block_parses_inline_markdown(self):
post = make_post()
post.footnote(2, "See [the source](https://example.com).")
block = footnotes(post)[0]
text_nodes = block["content"][0]["content"]
link_node = next(n for n in text_nodes if n.get("marks"))
assert link_node["text"] == "the source"
assert link_node["marks"] == [
{"type": "link", "attrs": {"href": "https://example.com"}}
]
# ---------------------------------------------------------------------------
# TestFromMarkdownFootnotes
# ---------------------------------------------------------------------------
class TestFromMarkdownFootnotes:
def test_basic_reference_and_definition(self):
post = make_post()
post.from_markdown("A claim.[^1]\n\n[^1]: The supporting detail.")
assert len(anchors(post)) == 1
assert anchors(post)[0]["attrs"]["number"] == 1
blocks = footnotes(post)
assert len(blocks) == 1
assert blocks[0]["attrs"]["number"] == 1
assert blocks[0]["content"][0]["content"][0]["text"] == "The supporting detail."
def test_definition_removed_from_body(self):
post = make_post()
post.from_markdown("A claim.[^1]\n\n[^1]: The note.")
# The definition line must not leak into a paragraph.
paragraphs = find_nodes(post.draft_body, "paragraph")
body_text = " ".join(
n.get("text", "") for p in paragraphs for n in p.get("content", [])
)
assert "[^1]:" not in body_text
def test_anchor_injected_mid_sentence(self):
post = make_post()
post.from_markdown("Before[^1] and after.\n\n[^1]: Note.")
para = find_nodes(post.draft_body, "paragraph")[0]
types = [c["type"] for c in para["content"]]
assert types == ["text", "footnoteAnchor", "text"]
assert para["content"][0]["text"] == "Before"
assert para["content"][2]["text"] == " and after."
def test_named_labels_numbered_by_first_appearance(self):
post = make_post()
md = (
"First[^book] then second[^study].\n\n"
"[^study]: Second definition.\n"
"[^book]: First definition.\n"
)
post.from_markdown(md)
nums = [a["attrs"]["number"] for a in anchors(post)]
assert nums == [1, 2] # order of reference, not of definition
blocks = sorted(footnotes(post), key=lambda b: b["attrs"]["number"])
assert blocks[0]["content"][0]["content"][0]["text"] == "First definition."
assert blocks[1]["content"][0]["content"][0]["text"] == "Second definition."
def test_repeated_reference_duplicates_footnote(self):
# Substack numbers anchors by position and pairs them 1:1 with footnote
# blocks, so a definition referenced twice yields two sequentially-numbered
# anchors and two footnote blocks with identical content.
post = make_post()
post.from_markdown("One[^a] two[^a].\n\n[^a]: Note.")
nums = [a["attrs"]["number"] for a in anchors(post)]
assert nums == [1, 2]
blocks = footnotes(post)
assert [b["attrs"]["number"] for b in blocks] == [1, 2]
assert blocks[0]["content"] == blocks[1]["content"]
assert blocks[0]["content"][0]["content"][0]["text"] == "Note."
def test_link_inside_definition_preserved(self):
post = make_post()
post.from_markdown("Claim.[^1]\n\n[^1]: See [docs](https://example.com).")
block = footnotes(post)[0]
link_node = next(n for n in block["content"][0]["content"] if n.get("marks"))
assert link_node["marks"][0]["attrs"]["href"] == "https://example.com"
def test_multiline_definition(self):
post = make_post()
md = "Claim.[^1]\n\n[^1]: First line\n continued on the next line."
post.from_markdown(md)
text = footnotes(post)[0]["content"][0]["content"][0]["text"]
assert text == "First line continued on the next line."
def test_unreferenced_definition_is_dropped(self):
# CommonMark footnote semantics: a definition that is never referenced is
# not rendered, and must not leak into the body text.
post = make_post()
post.from_markdown("No references here.\n\n[^1]: Orphan note.")
assert len(anchors(post)) == 0
assert len(footnotes(post)) == 0
paragraphs = find_nodes(post.draft_body, "paragraph")
body_text = " ".join(
n.get("text", "") for para in paragraphs for n in para.get("content", [])
)
assert "Orphan note" not in body_text
def test_reference_without_definition_left_as_text(self):
post = make_post()
post.from_markdown("A dangling[^missing] reference.")
assert len(anchors(post)) == 0
assert len(footnotes(post)) == 0
para = find_nodes(post.draft_body, "paragraph")[0]
assert "[^missing]" in para["content"][0]["text"]
def test_definition_in_middle_moves_to_end(self):
post = make_post()
md = "First paragraph.[^1]\n\n[^1]: First footnote.\n\nSecond paragraph."
post.from_markdown(md)
types = [node["type"] for node in body_content(post)]
# Both paragraphs come first; the footnote block is last regardless of
# where the definition appeared in the source.
assert types == ["paragraph", "paragraph", "footnote"]
paragraphs = find_nodes(post.draft_body, "paragraph")
assert paragraphs[0]["content"][0]["text"] == "First paragraph."
# The definition line did not become a paragraph in the body.
assert paragraphs[1]["content"][0]["text"] == "Second paragraph."
assert len(anchors(post)) == 1
block = footnotes(post)[0]
assert block["content"][0]["content"][0]["text"] == "First footnote."
def test_footnote_definition_inside_fenced_code_stays_code(self):
post = make_post()
post.from_markdown("```\n[^1]: not a footnote\n```")
content = body_content(post)
assert len(content) == 1
assert content[0]["type"] == "codeBlock"
assert content[0]["content"][0]["text"] == "[^1]: not a footnote"
def test_footnote_reference_inside_fenced_code_stays_text(self):
post = make_post()
post.from_markdown("```\ncode [^1]\n```\n\n[^1]: note")
content = body_content(post)
assert content[0]["type"] == "codeBlock"
assert content[0]["content"][0]["text"] == "code [^1]"
def test_footnote_reference_inside_inline_code_stays_text(self):
post = make_post()
post.from_markdown("`code [^1]`\n\n[^1]: note")
content = body_content(post)
assert content[0]["type"] == "paragraph"
assert content[0]["content"][0]["text"] == "code [^1]"
assert content[0]["content"][0]["marks"] == [{"type": "code"}]
def test_multiparagraph_definition(self):
post = make_post()
md = "Claim.[^1]\n\n[^1]: First para.\n\n Second para."
post.from_markdown(md)
# The second paragraph must stay in the footnote, not leak into the body.
assert [n["type"] for n in body_content(post)] == ["paragraph", "footnote"]
block = footnotes(post)[0]
assert len(block["content"]) == 2
assert block["content"][0]["content"][0]["text"] == "First para."
assert block["content"][1]["content"][0]["text"] == "Second para."
def test_multiparagraph_definition_in_middle(self):
post = make_post()
md = (
"First.[^1]\n\n"
"[^1]: Note para one.\n\n"
" Note para two.\n\n"
"Back to the body."
)
post.from_markdown(md)
types = [n["type"] for n in body_content(post)]
assert types == ["paragraph", "paragraph", "footnote"]
assert body_content(post)[1]["content"][0]["text"] == "Back to the body."
assert len(footnotes(post)[0]["content"]) == 2
def test_footnote_helper_splits_paragraphs(self):
post = make_post()
post.footnote(1, "Para one.\n\nPara two.")
block = footnotes(post)[0]
assert len(block["content"]) == 2
assert block["content"][1]["content"][0]["text"] == "Para two."
def test_definition_with_block_content_is_preserved(self):
# A footnote whose definition is a list (or other block content) must keep
# its text rather than dropping it.
post = make_post()
post.from_markdown("Claim[^1]\n\n[^1]: - item one\n - item two")
note = footnotes(post)[0]
text = " ".join(
n.get("text", "")
for para in find_nodes(note, "paragraph")
for n in para.get("content", [])
)
assert "item one" in text
assert "item two" in text
# rendered as a real nested list inside the footnote
assert find_nodes(note, "bullet_list")
def test_no_footnotes_is_unchanged(self):
post = make_post()
post.from_markdown("Just a plain paragraph.")
assert len(anchors(post)) == 0
assert len(footnotes(post)) == 0
assert find_nodes(post.draft_body, "paragraph")[0]["content"][0]["text"] == (
"Just a plain paragraph."
)