"""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 & 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 # The section becomes the sole Anki tag (as a one-element list). 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"])] # --- multi-tag headings, --tag-filter, --guid-salt ------------------------- MULTITAG = """* Fundamentals ** What is LEO? :fundamental:drill: Low Earth Orbit. ** What is GEO? :drill: Geostationary Earth Orbit. """ def test_parse_multitag_heading_is_a_card_when_drill_is_present(drill): """A heading with a second org tag still parses when drill is among them.""" cards = drill.parse(MULTITAG) assert len(cards) == 2 assert cards[0][0] == "What is LEO?" def test_parse_multitag_tags_ride_along_next_to_the_section_tag(drill): """Non-drill org tags become Anki tags alongside the section tag.""" cards = drill.parse(MULTITAG) assert cards[0][2] == ["fundamentals", "fundamental"] # section slug + org tag assert cards[1][2] == ["fundamentals"] # drill-only -> section only def test_parse_heading_without_drill_tag_is_not_a_card(drill): """A tagged heading missing :drill: is not a card (e.g. :note:).""" assert drill.parse("* S\n** Just a note :note:\nbody\n") == [] def test_parse_tag_filter_returns_only_cards_with_that_org_tag(drill): """--tag-filter narrows to cards carrying the given org tag.""" cards = drill.parse(MULTITAG, tag_filter="fundamental") assert len(cards) == 1 assert cards[0][0] == "What is LEO?" def test_parse_body_bounded_by_any_l1_or_l2_heading(drill): """A card body stops at the next L1/L2 heading, multi-tagged or not.""" text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n" cards = drill.parse(text) assert cards[0][1] == "body1" assert cards[1][1] == "body2" def test_card_guid_salt_changes_the_guid(drill, monkeypatch): """--guid-salt gives a subset deck its own GUID space; no salt is unchanged.""" monkeypatch.setattr(drill.genanki, "guid_for", lambda *a: ":".join(a), raising=False) assert drill.card_guid("front", None) == "front" assert drill.card_guid("front", "fundamentals") == "fundamentals:front" assert drill.card_guid("front", None) != drill.card_guid("front", "fundamentals") 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
line two" def test_parse_no_drill_cards_returns_empty(drill): assert drill.parse("* Section\nno drill cards here\n") == []