diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-30 13:17:47 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-30 13:17:47 -0500 |
| commit | 0234e52b727b34ade93961eb05b5638685f4406f (patch) | |
| tree | b7ee5f66a9fceb3fd4d9b1d2ba8c44e89dde76c5 /.ai/scripts/tests | |
| parent | 038d59b7e548d2323f43dcd92ba14cba876d840d (diff) | |
| download | rulesets-0234e52b727b34ade93961eb05b5638685f4406f.tar.gz rulesets-0234e52b727b34ade93961eb05b5638685f4406f.zip | |
chore(scripts): add drill-deck stats, diff-ids, and sync wrapper
I incorporated the flashcard-tooling bundle from the work project's deck-review workflow, validated there against a 93-card deck. Three scripts now live under .ai/scripts/: drill-deck-stats.py (pre-rewrite inventory plus a gate that warns on stray *** Answer headers, missing :ID:, non-prompt headings, and #+TITLE jargon like "org-drill"), drill-deck-diff-ids.py (SRS-state preservation check that flags any :ID: lost across a rewrite), and drill-deck-sync (bash wrapper chaining stats, optional diff-ids, then drill-to-anki, writing to ~/sync/phone/anki/ only when the gates pass).
The drill-deck-review.org workflow gains a Helper Scripts section and references the scripts from its phases. I reconciled its output-path prose with the drill-to-anki default that just moved to ~/sync/phone/anki/, so it no longer claims the script still defaults to ~/sync/org/drill/. I added tests for both Python scripts (pure logic plus CLI gate behavior) and a bats suite for the wrapper's guard paths. The clean end-to-end sync path stays uncovered since it needs uv-resolved genanki.
Diffstat (limited to '.ai/scripts/tests')
| -rw-r--r-- | .ai/scripts/tests/drill-deck-sync.bats | 38 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_drill_deck_diff_ids.py | 88 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_drill_deck_stats.py | 96 |
3 files changed, 222 insertions, 0 deletions
diff --git a/.ai/scripts/tests/drill-deck-sync.bats b/.ai/scripts/tests/drill-deck-sync.bats new file mode 100644 index 0000000..e141cab --- /dev/null +++ b/.ai/scripts/tests/drill-deck-sync.bats @@ -0,0 +1,38 @@ +#!/usr/bin/env bats +# Tests for the drill-deck-sync wrapper: argument handling + the stats gate. +# The clean end-to-end path runs drill-to-anki.py (uv-resolved genanki) and is +# not exercised here; these cover the guard paths that stop before that step. + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + SYNC="$SCRIPT_DIR/drill-deck-sync" + TMP="$(mktemp -d)" +} + +teardown() { + rm -rf "$TMP" +} + +@test "drill-deck-sync: no args exits 2" { + run "$SYNC" + [ "$status" -eq 2 ] +} + +@test "drill-deck-sync: missing source file exits 2" { + run "$SYNC" "$TMP/nope.org" + [ "$status" -eq 2 ] +} + +@test "drill-deck-sync: stats gate failure exits 1 and writes no apkg" { + cat > "$TMP/dirty.org" <<'EOF' +#+TITLE: DeepSat Org-Drill Flashcards + +* Section +** DeepSat :drill: +*** Answer +A satellite company. +EOF + run "$SYNC" "$TMP/dirty.org" + [ "$status" -eq 1 ] + [ ! -f "$HOME/sync/phone/anki/dirty.apkg" ] +} diff --git a/.ai/scripts/tests/test_drill_deck_diff_ids.py b/.ai/scripts/tests/test_drill_deck_diff_ids.py new file mode 100644 index 0000000..9cd8305 --- /dev/null +++ b/.ai/scripts/tests/test_drill_deck_diff_ids.py @@ -0,0 +1,88 @@ +"""Tests for drill-deck-diff-ids.py: :ID: extraction + SRS-state diff CLI. + +Plain python3 script (no third-party deps), so card_id_map imports directly; +the disappeared/appeared reporting is exercised through the CLI. +""" +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-diff-ids.py" + + +@pytest.fixture(scope="module") +def diff_ids(): + spec = importlib.util.spec_from_file_location("drill_deck_diff_ids", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +DECK_A = """* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: id-1 +:END: +Body. +** Who founded it? :drill: +:PROPERTIES: +:ID: id-2 +:END: +Body. +""" + +# id-2 dropped, id-3 added relative to DECK_A +DECK_B = """* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: id-1 +:END: +Body. +** When was it founded? :drill: +:PROPERTIES: +:ID: id-3 +:END: +Body. +""" + + +def test_card_id_map_extracts_id_to_heading(diff_ids, tmp_path): + f = tmp_path / "a.org" + f.write_text(DECK_A) + m = diff_ids.card_id_map(f) + assert set(m) == {"id-1", "id-2"} + assert m["id-1"] == "What is DeepSat?" + + +def _run(before, after): + return subprocess.run( + [sys.executable, str(SCRIPT), str(before), str(after)], + capture_output=True, text=True, + ) + + +def test_cli_identical_decks_exit_zero(tmp_path): + a = tmp_path / "a.org" + a.write_text(DECK_A) + b = tmp_path / "b.org" + b.write_text(DECK_A) + r = _run(a, b) + assert r.returncode == 0 + assert "preserved" in r.stdout.lower() + + +def test_cli_dropped_id_warns_and_exits_one(tmp_path): + a = tmp_path / "a.org" + a.write_text(DECK_A) + b = tmp_path / "b.org" + b.write_text(DECK_B) + r = _run(a, b) + assert r.returncode == 1 + assert "disappeared" in r.stdout.lower() + assert "id-2" in r.stdout diff --git a/.ai/scripts/tests/test_drill_deck_stats.py b/.ai/scripts/tests/test_drill_deck_stats.py new file mode 100644 index 0000000..02d9c4e --- /dev/null +++ b/.ai/scripts/tests/test_drill_deck_stats.py @@ -0,0 +1,96 @@ +"""Tests for drill-deck-stats.py: prompt-form heuristic + CLI inventory/gate. + +Plain python3 script (no third-party deps), so the pure helper imports directly; +the inventory/gate behavior is exercised through the CLI. +""" +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-stats.py" + + +@pytest.fixture(scope="module") +def stats(): + spec = importlib.util.spec_from_file_location("drill_deck_stats", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# --- is_prompt_form (pure) --- + +def test_is_prompt_form_question_mark(stats): + assert stats.is_prompt_form("What is DeepSat?") is True + + +def test_is_prompt_form_imperative_verb(stats): + assert stats.is_prompt_form("Spell out the orbital regimes") is True + + +def test_is_prompt_form_imperative_is_case_insensitive(stats): + assert stats.is_prompt_form("introduce yourself") is True + + +def test_is_prompt_form_topic_heading_is_not_a_prompt(stats): + assert stats.is_prompt_form("DeepSat") is False + + +def test_is_prompt_form_strips_trailing_punctuation_off_first_word(stats): + assert stats.is_prompt_form("List: the founders") is True + + +# --- CLI inventory + gate (integration) --- + +CLEAN_DECK = """#+TITLE: DeepSat Flashcards + +* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: card-1 +:END: +A satellite company. +""" + +DIRTY_DECK = """#+TITLE: DeepSat Org-Drill Flashcards + +* Section +** DeepSat :drill: +*** Answer +A satellite company. +""" + + +def _run(path): + return subprocess.run( + [sys.executable, str(SCRIPT), str(path)], + capture_output=True, text=True, + ) + + +def test_cli_clean_deck_exits_zero(tmp_path): + f = tmp_path / "clean.org" + f.write_text(CLEAN_DECK) + r = _run(f) + assert r.returncode == 0 + assert "clean" in r.stdout + + +def test_cli_dirty_deck_warns_and_exits_one(tmp_path): + f = tmp_path / "dirty.org" + f.write_text(DIRTY_DECK) + r = _run(f) + assert r.returncode == 1 + assert "WARN" in r.stdout + assert "org-drill" in r.stdout.lower() # title-jargon audit fired + + +def test_cli_missing_file_exits_two(tmp_path): + r = _run(tmp_path / "nope.org") + assert r.returncode == 2 |
