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
|
"""Tests for flashcard-to-anki.py default-path and deck-name helpers.
The script is a PEP 723 uv-run script that imports genanki, which uv resolves
at runtime but isn't installed in the test environment. The fixture stubs
genanki in sys.modules so the module loads; the pure helpers under test never
call into it.
"""
from __future__ import annotations
import importlib.util
import sys
import types
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-to-anki.py"
@pytest.fixture(scope="module")
def drill():
# Only stub when genanki is genuinely absent, so a real install isn't shadowed.
sys.modules.setdefault("genanki", types.ModuleType("genanki"))
spec = importlib.util.spec_from_file_location("flashcard_to_anki", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_default_output_path_targets_phone_anki_dir(drill):
"""The .apkg is a phone artifact, so it defaults under sync/phone/anki/."""
result = drill.default_output_path(Path("/home/x/projects/health/health-drill.org"))
assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg"
def test_default_deck_name_is_raw_basename(drill):
"""Deck name is the input basename with case preserved; #+TITLE is ignored."""
assert drill.default_deck_name(Path("/x/deepsat.org")) == "deepsat"
def test_default_deck_name_keeps_hyphens(drill):
"""A hyphenated basename is kept verbatim rather than title-cased."""
assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill"
# --- section_to_tag (pure) ---
def test_section_to_tag_slugifies_words(drill):
assert drill.section_to_tag("Orbital Regimes") == "orbital-regimes"
def test_section_to_tag_strips_leading_and_trailing_nonalnum(drill):
assert drill.section_to_tag(" People & Roles! ") == "people-roles"
def test_section_to_tag_empty_string(drill):
assert drill.section_to_tag("") == ""
# --- escape_html (pure) ---
def test_escape_html_escapes_amp_lt_gt(drill):
assert drill.escape_html("a & b < c > d") == "a & b < c > d"
def test_escape_html_plain_text_unchanged(drill):
assert drill.escape_html("plain text") == "plain text"
def test_escape_html_escapes_amp_first_so_existing_entity_is_literal(drill):
# & is replaced before < / >, so a literal "<" becomes "&lt;",
# not silently treated as an already-escaped entity.
assert drill.escape_html("<") == "&lt;"
def test_escape_html_empty_string(drill):
assert drill.escape_html("") == ""
# --- stable_id (pure) ---
def test_stable_id_is_deterministic(drill):
assert drill.stable_id("DeepSat", "deck") == drill.stable_id("DeepSat", "deck")
def test_stable_id_salt_changes_the_result(drill):
assert drill.stable_id("DeepSat", "deck") != drill.stable_id("DeepSat", "model")
def test_stable_id_stays_within_the_reserved_range(drill):
value = drill.stable_id("anything", "deck")
assert drill.ID_BASE <= value < drill.ID_BASE + drill.ID_RANGE
# --- strip_org_metadata (pure) ---
def test_strip_org_metadata_drops_properties_drawer(drill):
body = [":PROPERTIES:", ":ID: x", ":END:", "real content"]
assert drill.strip_org_metadata(body) == ["real content"]
def test_strip_org_metadata_drops_planning_lines(drill):
body = ["SCHEDULED: <2026-05-30>", "DEADLINE: <2026-06-01>",
"CLOSED: [2026-05-29]", "body"]
assert drill.strip_org_metadata(body) == ["body"]
def test_strip_org_metadata_leaves_plain_body_unchanged(drill):
body = ["line one", "line two"]
assert drill.strip_org_metadata(body) == ["line one", "line two"]
def test_strip_org_metadata_empty_list(drill):
assert drill.strip_org_metadata([]) == []
def test_strip_org_metadata_unclosed_drawer_swallows_the_rest(drill):
# An unterminated :PROPERTIES: drawer consumes everything after it.
body = [":PROPERTIES:", ":ID: x", "still in drawer"]
assert drill.strip_org_metadata(body) == []
def test_strip_org_metadata_drops_created_date_line(drill):
# A created/added date never belongs on a card back.
assert drill.strip_org_metadata(["Created: 2026-05-30", "real answer"]) == ["real answer"]
# --- parse (pure, core parser) ---
SECTIONED = """* Orbital Regimes
** What is LEO? :drill:
Low Earth Orbit.
** What is GEO? :drill:
Geostationary Earth Orbit.
"""
def test_parse_returns_front_back_tag_per_card(drill):
cards = drill.parse(SECTIONED)
assert len(cards) == 2
assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes")
assert cards[1][0] == "What is GEO?"
def test_parse_card_without_a_section_gets_the_drill_tag(drill):
assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")]
def test_parse_strips_properties_drawer_from_back(drill):
text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n"
assert drill.parse(text) == [("Q?", "The answer.", "drill")]
def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
cards = drill.parse("** Q? :drill:\n\n\nanswer\n\n\n")
assert cards[0][1] == "answer"
def test_parse_card_with_only_a_drawer_has_empty_back(drill):
text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n"
assert drill.parse(text) == [("Q?", "", "drill")]
def test_parse_joins_multiline_body_with_br(drill):
cards = drill.parse("** Q? :drill:\nline one\nline two\n")
assert cards[0][1] == "line one<br>line two"
def test_parse_no_drill_cards_returns_empty(drill):
assert drill.parse("* Section\nno drill cards here\n") == []
|