aboutsummaryrefslogtreecommitdiff
path: root/docs/design/2026-06-21-anki-titlefix-test.py
blob: 87008a841c6aa6cc7144e3eab60b3fb8d90ed665 (plain)
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
"""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_uses_org_title(drill):
    """The #+TITLE drives the Anki deck name, not the filename slug."""
    org = "#+TITLE: Refutations\n* Section\n** Q? :drill:\na\n"
    assert drill.default_deck_name(Path("/x/refutation-drill.org"), org) == "Refutations"


def test_default_deck_name_title_is_trimmed(drill):
    """Surrounding whitespace on the #+TITLE value is stripped."""
    org = "#+TITLE:   DeepSat Flashcards   \n"
    assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "DeepSat Flashcards"


def test_default_deck_name_title_match_is_case_insensitive(drill):
    """A lowercase #+title: keyword is still recognized."""
    org = "#+title: Health Flashcards\n"
    assert drill.default_deck_name(Path("/x/health-drill.org"), org) == "Health Flashcards"


def test_default_deck_name_falls_back_to_basename_without_title(drill):
    """No #+TITLE line falls back to the input basename, case preserved."""
    org = "* Section\n** Q? :drill:\na\n"
    assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "deepsat"


def test_default_deck_name_blank_title_falls_back_to_basename(drill):
    """An empty #+TITLE value is ignored in favour of the basename."""
    assert drill.default_deck_name(Path("/x/health-drill.org"), "#+TITLE:   \n") == "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 &amp; b &lt; c &gt; 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 "&lt;" becomes "&amp;lt;",
    # not silently treated as an already-escaped entity.
    assert drill.escape_html("&lt;") == "&amp;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") == []