aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-19 18:40:22 -0500
committerCraig Jennings <c@cjennings.net>2026-07-19 18:40:22 -0500
commita14e43b566946ba45abf58f75eb0a445b5b67ef3 (patch)
tree64dfcd6358a4839b56275efbbe761573e22de15f /.ai/scripts/tests
parenta143679a50f5d3a84aea3ed4effcb3ac9e5af69f (diff)
downloadrulesets-a14e43b566946ba45abf58f75eb0a445b5b67ef3.tar.gz
rulesets-a14e43b566946ba45abf58f75eb0a445b5b67ef3.zip
feat(flashcard): support multi-tag drill headings + --tag-filter / --guid-salt
Craig wanted a curated "DeepSat Fundamentals" deck: the ~100 most fundamental cards out of the 465-card deepsat.org deck, marked with a second org tag (:fundamental:) so they stay grep-able in the source. Both flashcard-to-anki.py and flashcard-stats.py keyed a card on a heading ending in exactly :drill:, so adding any second tag turned the heading into :fundamental:drill:, which that anchor didn't match. The 100 tagged cards would have silently dropped from the full-deck apkg and been undercounted by stats. That's the passing-gate-skips-your-file failure. CARD_RE now matches a trailing org tag block, and a heading is a card when drill is among its tags. The other tags ride along as Anki tags next to the section tag. Card bodies are bounded by any L1/L2 heading (HEADING_RE) rather than only a section or a drill heading. flashcard-to-anki.py gains --tag-filter <tag> (emit only cards carrying that org tag, the subset deck) and --guid-salt <s> (a distinct GUID space so the subset imports non-empty instead of colliding with the full deck on GUID and importing empty). No salt is unchanged, so the existing deck's GUIDs and SRS state are untouched. flashcard-stats.py gets the same CARD_RE/HEADING_RE broadening plus a drill-membership guard. This is the rulesets-canonical reconcile of a stopgap the work project ran locally. I re-derived it against the current canonical rather than copying the preserved files, which predate the #+TITLE deck-name fix (060a938) and would have reverted it. parse()'s third element is now the Anki-tag list rather than a single tag string. Only build() and the tests consumed it. The existing parse tests move to the list contract, and new tests cover multi-tag parsing, --tag-filter, the guid salt, and the stats count. A bats case guards that a :fundamental:drill: card still counts. The full deck's real 465/100 check needs the work deck and isn't runnable here. Verified end to end instead on a synthetic multi-tag deck (2 cards, --tag-filter fundamental returns 1) through the real genanki path.
Diffstat (limited to '.ai/scripts/tests')
-rw-r--r--.ai/scripts/tests/flashcard-sync.bats25
-rw-r--r--.ai/scripts/tests/test_apkg_to_orgdrill.py6
-rw-r--r--.ai/scripts/tests/test_flashcard_stats.py25
-rw-r--r--.ai/scripts/tests/test_flashcard_to_anki.py61
4 files changed, 111 insertions, 6 deletions
diff --git a/.ai/scripts/tests/flashcard-sync.bats b/.ai/scripts/tests/flashcard-sync.bats
index 608a280..e6ffc21 100644
--- a/.ai/scripts/tests/flashcard-sync.bats
+++ b/.ai/scripts/tests/flashcard-sync.bats
@@ -6,6 +6,7 @@
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
SYNC="$SCRIPT_DIR/flashcard-sync"
+ STATS="$SCRIPT_DIR/flashcard-stats.py"
TMP="$(mktemp -d)"
}
@@ -36,3 +37,27 @@ EOF
[ "$status" -eq 1 ]
[ ! -f "$HOME/sync/phone/anki/dirty.apkg" ]
}
+
+@test "flashcard-stats: a multi-tagged :fundamental:drill: card still counts" {
+ # Regression guard: a curated card carrying a second org tag must not drop
+ # from the count. A :drill:$ anchor would have counted only one card here.
+ cat > "$TMP/multitag.org" <<'EOF'
+#+TITLE: Multitag Test
+
+* Orbital Regimes
+** What is LEO? :fundamental:drill:
+:PROPERTIES:
+:ID: c1
+:END:
+Low Earth Orbit is the region below about 2000 kilometers.
+** What is GEO? :drill:
+:PROPERTIES:
+:ID: c2
+:END:
+Geostationary orbit sits at roughly 35786 kilometers of altitude.
+EOF
+ run python3 "$STATS" "$TMP/multitag.org"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Cards: 2"* ]]
+ [[ "$output" == *clean* ]]
+}
diff --git a/.ai/scripts/tests/test_apkg_to_orgdrill.py b/.ai/scripts/tests/test_apkg_to_orgdrill.py
index adffef0..6a95ea4 100644
--- a/.ai/scripts/tests/test_apkg_to_orgdrill.py
+++ b/.ai/scripts/tests/test_apkg_to_orgdrill.py
@@ -264,7 +264,7 @@ def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path):
"** Capital of France? :drill:\n"
"Paris.\n"
)
- tuples = forward.parse(original) # [(front, back_html, tag), ...]
+ tuples = forward.parse(original) # [(front, back_html, anki_tags), ...]
assert len(tuples) == 2
apkg = tmp_path / "rt.apkg"
@@ -272,7 +272,9 @@ def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path):
apkg,
decks={20: "RT Deck"},
models={9: ["Front", "Back"]},
- notes=[(100 + i, 9, [f, b], t) for i, (f, b, t) in enumerate(tuples)],
+ # anki_tags is a list; the apkg tags field is space-joined.
+ notes=[(100 + i, 9, [f, b], " ".join(tags))
+ for i, (f, b, tags) in enumerate(tuples)],
cards=[(100 + i, 20) for i in range(len(tuples))],
)
diff --git a/.ai/scripts/tests/test_flashcard_stats.py b/.ai/scripts/tests/test_flashcard_stats.py
index 606f7c1..46deccc 100644
--- a/.ai/scripts/tests/test_flashcard_stats.py
+++ b/.ai/scripts/tests/test_flashcard_stats.py
@@ -217,6 +217,31 @@ def test_parse_cards_captures_body_without_drawer_planning_or_answer_header(stat
assert c["body"] == "the real answer"
+def test_parse_cards_counts_a_multitag_heading_as_a_card(stats):
+ """A card multi-tagged :fundamental:drill: still counts; the front is clean."""
+ text = "* Sec\n** Q multi? :fundamental:drill:\nthe answer\n"
+ cards, _ = stats.parse_cards(text.splitlines())
+ assert len(cards) == 1
+ assert cards[0]["heading"] == "Q multi?"
+ assert cards[0]["body"] == "the answer"
+
+
+def test_parse_cards_ignores_a_tagged_heading_without_drill(stats):
+ """A tagged heading missing :drill: is not a drill card."""
+ text = "* Sec\n** Just a note :note:\nbody\n"
+ cards, _ = stats.parse_cards(text.splitlines())
+ assert cards == []
+
+
+def test_parse_cards_body_stops_at_next_multitag_card(stats):
+ """The body scan ends at the next L2 card even when it is multi-tagged."""
+ text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n"
+ cards, _ = stats.parse_cards(text.splitlines())
+ assert len(cards) == 2
+ assert cards[0]["body"] == "body1"
+ assert cards[1]["body"] == "body2"
+
+
def test_find_duplicate_fronts_matches_normalized_headings(stats):
cards = [
{"heading": "What is LEO?"},
diff --git a/.ai/scripts/tests/test_flashcard_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py
index 87008a8..fa38b64 100644
--- a/.ai/scripts/tests/test_flashcard_to_anki.py
+++ b/.ai/scripts/tests/test_flashcard_to_anki.py
@@ -158,17 +158,18 @@ 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")
+ # 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")]
+ 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")]
+ assert drill.parse(text) == [("Q?", "The answer.", ["drill"])]
def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
@@ -178,7 +179,59 @@ def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
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")]
+ 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):