aboutsummaryrefslogtreecommitdiff
path: root/docs/design/2026-06-21-anki-titlefix-test.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-22 01:35:53 -0400
committerCraig Jennings <c@cjennings.net>2026-06-22 01:35:53 -0400
commitd961c783d18c6178751b338ef1d8dd6a72db9f20 (patch)
tree0b3ac2a1a9eea1a38a46da6cd361d9c766c8fd41 /docs/design/2026-06-21-anki-titlefix-test.py
parent91217d9b7f176e8a051d36672519552b7d5352b7 (diff)
downloadrulesets-d961c783d18c6178751b338ef1d8dd6a72db9f20.tar.gz
rulesets-d961c783d18c6178751b338ef1d8dd6a72db9f20.zip
docs: file flashcard, host-identity, and coverage-location backlog tasks
Files the remaining inbox proposals as prioritized rulesets tasks, with their design docs preserved under docs/design: the Anki #+TITLE deck-name bug (edited code ready), the apkg to org-drill converter, flashcard-stats refutation mode, an archsetup host-identity guard, the coverage-summary install-location question, and an optional tooling-path enumeration hook.
Diffstat (limited to 'docs/design/2026-06-21-anki-titlefix-test.py')
-rw-r--r--docs/design/2026-06-21-anki-titlefix-test.py190
1 files changed, 190 insertions, 0 deletions
diff --git a/docs/design/2026-06-21-anki-titlefix-test.py b/docs/design/2026-06-21-anki-titlefix-test.py
new file mode 100644
index 0000000..87008a8
--- /dev/null
+++ b/docs/design/2026-06-21-anki-titlefix-test.py
@@ -0,0 +1,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") == []