diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-31 12:19:34 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-31 12:19:34 -0500 |
| commit | ddf48dc7ac780da1aacdff4e03f1d7da255b8f39 (patch) | |
| tree | 99926b681a9ea6d4210d0dcd1bd8e8a6d47d7d9e /.ai/scripts/tests/test_flashcard_to_anki.py | |
| parent | b46619cd17ed4e36f2e59c1b600078521b2049ef (diff) | |
| download | rulesets-ddf48dc7ac780da1aacdff4e03f1d7da255b8f39.tar.gz rulesets-ddf48dc7ac780da1aacdff4e03f1d7da255b8f39.zip | |
feat: add rename-ai-artifact tool and rename the drill-deck family to flashcard
Renaming an .ai artifact by hand is the kind of mechanical job that gets done incompletely: the canonical copy moves but the mirror doesn't, a reference in the INDEX is missed, a trigger phrase points at the old name. I'd also assumed a rename was costly because references scatter, when the index update is trivial and the drift check already guards it. So I built the discipline into a script instead of re-deriving it each time.
scripts/rename-ai-artifact.sh takes old and new basenames, moves the file in both the canonical and mirror trees, and rewrites every reference repo-wide on a token boundary so renaming "foo" can't corrupt "foobar" or "foo-bar". It rewrites the underscore module-name variant too (a hyphenated script imported as foo_bar via importlib), leaves the archived session records under sessions/ alone because they're history, and runs workflow-integrity + sync-check at the end to prove no drift. rename-artifact.org documents it and indexes the triggers.
Then I used the tool to do the rename that prompted it: the org-drill deck workflow and its helpers are now flashcard-named, since "flashcard" is the word you'd actually search for. The renamed set is flashcard-review.org plus flashcard-stats.py, flashcard-sync, flashcard-to-anki.py, and flashcard-diff-ids.py, with their tests, every reference, and the INDEX entry updated. The deck is still an org-drill deck under the hood, so the ":drill:" tag handling and the "drill deck" trigger phrases stay. I added "review/update the flashcards" alongside them.
Tests: 9 bats for the rename tool (including the prefix-collision and history-preservation edges), and the renamed script suites all pass under make test.
Diffstat (limited to '.ai/scripts/tests/test_flashcard_to_anki.py')
| -rw-r--r-- | .ai/scripts/tests/test_flashcard_to_anki.py | 171 |
1 files changed, 171 insertions, 0 deletions
diff --git a/.ai/scripts/tests/test_flashcard_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py new file mode 100644 index 0000000..058b0cd --- /dev/null +++ b/.ai/scripts/tests/test_flashcard_to_anki.py @@ -0,0 +1,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") == [] |
