branch: elpa/gnosis
commit 2b9736521c0cab7c7fc095c2cfa5c5fff441044f
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
[new] tests: Add tests for cloze extraction.
---
tests/gnosis-test-cloze.el | 442 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 442 insertions(+)
diff --git a/tests/gnosis-test-cloze.el b/tests/gnosis-test-cloze.el
new file mode 100644
index 0000000000..5f43401ab1
--- /dev/null
+++ b/tests/gnosis-test-cloze.el
@@ -0,0 +1,442 @@
+;;; gnosis-test-cloze.el --- Cloze extraction tests -*- lexical-binding: t;
-*-
+
+;; Copyright (C) 2026 Free Software Foundation, Inc.
+
+;; Author: Thanos Apollo <[email protected]>
+
+;;; Commentary:
+
+;; Tests for cloze extraction, tag removal, and Anki-like syntax
+;; ({{cN::answer::hint}}). All tested functions are pure — no database needed.
+
+;;; Code:
+
+(require 'ert)
+(require 'gnosis)
+
+(let ((parent-dir (file-name-directory
+ (directory-file-name
+ (file-name-directory (or load-file-name
default-directory))))))
+ (add-to-list 'load-path parent-dir))
+
+;; ──────────────────────────────────────────────────────────
+;; gnosis-cloze-extract-contents
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-cloze-extract-single ()
+ "Extract a single cloze with double braces and double colons."
+ (let ((result (gnosis-cloze-extract-contents "{{c1::Penicillin}}")))
+ (should (equal result '(("Penicillin"))))))
+
+(ert-deftest gnosis-test-cloze-extract-single-brace ()
+ "Extract a single cloze with single braces and single colon."
+ (let ((result (gnosis-cloze-extract-contents "{c1:Penicillin}")))
+ (should (equal result '(("Penicillin"))))))
+
+(ert-deftest gnosis-test-cloze-extract-with-hint ()
+ "Extract cloze with hint — hint is included in extracted content."
+ (let ((result (gnosis-cloze-extract-contents
+ "{{c1::Penicillin::antibiotic}}")))
+ (should (equal result '(("Penicillin::antibiotic"))))))
+
+(ert-deftest gnosis-test-cloze-extract-multiple-groups ()
+ "Extract clozes from different cX groups."
+ (let ((result (gnosis-cloze-extract-contents
+ "{{c1::alpha}} and {{c2::beta}}")))
+ (should (= (length result) 2))
+ (should (equal (nth 0 result) '("alpha")))
+ (should (equal (nth 1 result) '("beta")))))
+
+(ert-deftest gnosis-test-cloze-extract-same-group ()
+ "Multiple clozes in the same cX group are collected together."
+ (let ((result (gnosis-cloze-extract-contents
+ "{{c1::alpha}} and {{c1::beta}}")))
+ (should (= (length result) 1))
+ (should (equal (car result) '("alpha" "beta")))))
+
+(ert-deftest gnosis-test-cloze-extract-mixed-groups-with-hints ()
+ "Multiple groups, some with hints."
+ (let ((result (gnosis-cloze-extract-contents
+ "{{c1::drug::type}} treats {{c2::infection}}")))
+ (should (= (length result) 2))
+ (should (equal (nth 0 result) '("drug::type")))
+ (should (equal (nth 1 result) '("infection")))))
+
+(ert-deftest gnosis-test-cloze-extract-anki-mc-hint ()
+ "Anki-style hint with options (rapid or slow) is preserved."
+ (let ((result (gnosis-cloze-extract-contents
+ "{{c1::rapid::rapid or slow}}-onset food poisoning")))
+ (should (equal (car result) '("rapid::rapid or slow")))))
+
+(ert-deftest gnosis-test-cloze-extract-inline-text ()
+ "Clozes embedded in longer text are extracted correctly."
+ (let ((result (gnosis-cloze-extract-contents
+ "The {{c1::mitochondria}} is the {{c2::powerhouse}} of the
cell")))
+ (should (= (length result) 2))
+ (should (equal (nth 0 result) '("mitochondria")))
+ (should (equal (nth 1 result) '("powerhouse")))))
+
+(ert-deftest gnosis-test-cloze-extract-no-cloze ()
+ "Plain text without clozes returns empty list."
+ (should (null (gnosis-cloze-extract-contents "No clozes here"))))
+
+;; ──────────────────────────────────────────────────────────
+;; gnosis-cloze-extract-answers
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-cloze-answers-no-hint ()
+ "Answers without hints are returned unchanged."
+ (let ((result (gnosis-cloze-extract-answers '(("Penicillin")))))
+ (should (equal result '(("Penicillin"))))))
+
+(ert-deftest gnosis-test-cloze-answers-strip-hint ()
+ "Hint portion after :: is stripped from answers."
+ (let ((result (gnosis-cloze-extract-answers
+ '(("Penicillin::antibiotic")))))
+ (should (equal result '(("Penicillin"))))))
+
+(ert-deftest gnosis-test-cloze-answers-strip-mc-hint ()
+ "MC-style hints (rapid or slow) are stripped from answers."
+ (let ((result (gnosis-cloze-extract-answers
+ '(("rapid::rapid or slow")))))
+ (should (equal result '(("rapid"))))))
+
+(ert-deftest gnosis-test-cloze-answers-multiple-groups ()
+ "Answers are extracted per group, preserving nesting."
+ (let ((result (gnosis-cloze-extract-answers
+ '(("drug::type") ("infection")))))
+ (should (equal result '(("drug") ("infection"))))))
+
+(ert-deftest gnosis-test-cloze-answers-same-group-multiple ()
+ "Multiple clozes in one group all get their hints stripped."
+ (let ((result (gnosis-cloze-extract-answers
+ '(("cleft lip::face" "cleft palate::face")))))
+ (should (equal result '(("cleft lip" "cleft palate"))))))
+
+;; ──────────────────────────────────────────────────────────
+;; gnosis-cloze-extract-hints
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-cloze-hints-present ()
+ "Hints are extracted from content with :: separator."
+ (let ((result (gnosis-cloze-extract-hints
+ '(("Penicillin::antibiotic")))))
+ (should (equal result '(("antibiotic"))))))
+
+(ert-deftest gnosis-test-cloze-hints-absent ()
+ "Content without hints yields nil for that position."
+ (let ((result (gnosis-cloze-extract-hints '(("Penicillin")))))
+ (should (equal result '((nil))))))
+
+(ert-deftest gnosis-test-cloze-hints-mc-options ()
+ "MC-style hint (rapid or slow) is extracted as a single string."
+ (let ((result (gnosis-cloze-extract-hints
+ '(("rapid::rapid or slow")))))
+ (should (equal result '(("rapid or slow"))))))
+
+(ert-deftest gnosis-test-cloze-hints-mixed ()
+ "Groups with and without hints are handled correctly."
+ (let ((result (gnosis-cloze-extract-hints
+ '(("drug::type") ("infection")))))
+ (should (equal (nth 0 result) '("type")))
+ (should (equal (nth 1 result) '(nil)))))
+
+(ert-deftest gnosis-test-cloze-hints-same-group ()
+ "Multiple clozes in same group each get their own hint."
+ (let ((result (gnosis-cloze-extract-hints
+ '(("cleft lip::face" "cleft palate::face")))))
+ (should (equal result '(("face" "face"))))))
+
+;; ──────────────────────────────────────────────────────────
+;; gnosis-cloze-remove-tags
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-cloze-remove-double-brace ()
+ "Double-brace cloze tags are removed, leaving content."
+ (should (string= (gnosis-cloze-remove-tags "{{c1::Penicillin}}")
+ "Penicillin")))
+
+(ert-deftest gnosis-test-cloze-remove-single-brace ()
+ "Single-brace cloze tags are removed."
+ (should (string= (gnosis-cloze-remove-tags "{c1:drug}")
+ "drug")))
+
+(ert-deftest gnosis-test-cloze-remove-with-hint ()
+ "Cloze tags with hints are removed — only content remains, hint dropped."
+ (should (string= (gnosis-cloze-remove-tags "{{c1::Penicillin::antibiotic}}")
+ "Penicillin")))
+
+(ert-deftest gnosis-test-cloze-remove-mc-hint ()
+ "MC-style hint is also removed."
+ (should (string= (gnosis-cloze-remove-tags
+ "{{c1::rapid::rapid or slow}}-onset food poisoning")
+ "rapid-onset food poisoning")))
+
+(ert-deftest gnosis-test-cloze-remove-multiple ()
+ "Multiple cloze tags in a sentence are all removed."
+ (should (string= (gnosis-cloze-remove-tags
+ "{{c1::drug::type}} treats {{c2::infections}}")
+ "drug treats infections")))
+
+(ert-deftest gnosis-test-cloze-remove-preserves-plain-text ()
+ "Text without cloze tags is returned unchanged."
+ (should (string= (gnosis-cloze-remove-tags "No clozes here")
+ "No clozes here")))
+
+(ert-deftest gnosis-test-cloze-remove-same-group-multiple ()
+ "Multiple clozes in same group with hints are all cleaned."
+ (should (string= (gnosis-cloze-remove-tags
+ "{{c2::cleft lip::face}} and {{c2::cleft palate::face}}")
+ "cleft lip and cleft palate")))
+
+;; ──────────────────────────────────────────────────────────
+;; gnosis-cloze-check
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-cloze-check-found ()
+ "Cloze check returns t when all answers are in the sentence."
+ (should (gnosis-cloze-check "Penicillin treats infections"
+ '("Penicillin"))))
+
+(ert-deftest gnosis-test-cloze-check-multiple-found ()
+ "Cloze check with multiple answers all present."
+ (should (gnosis-cloze-check "Penicillin treats bacterial infections"
+ '("Penicillin" "infections"))))
+
+(ert-deftest gnosis-test-cloze-check-missing ()
+ "Cloze check returns nil when an answer is not in the sentence."
+ (should-not (gnosis-cloze-check "Penicillin treats infections"
+ '("Amoxicillin"))))
+
+(ert-deftest gnosis-test-cloze-check-nil-answer ()
+ "Cloze check with nil answer list returns t (vacuously true)."
+ (should (gnosis-cloze-check "Anything" nil)))
+
+;; ──────────────────────────────────────────────────────────
+;; Full pipeline: extract → answers/hints from Anki syntax
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-cloze-pipeline-anki-simple ()
+ "Full pipeline: Anki-like {{c1::answer}} without hint."
+ (let* ((text "The {{c1::mitochondria}} is the powerhouse of the cell")
+ (contents (gnosis-cloze-extract-contents text))
+ (answers (gnosis-cloze-extract-answers contents))
+ (hints (gnosis-cloze-extract-hints contents))
+ (clean (gnosis-cloze-remove-tags text)))
+ (should (equal answers '(("mitochondria"))))
+ (should (equal hints '((nil))))
+ (should (string= clean "The mitochondria is the powerhouse of the cell"))
+ (should (gnosis-cloze-check clean (car answers)))))
+
+(ert-deftest gnosis-test-cloze-pipeline-anki-with-hint ()
+ "Full pipeline: Anki-like {{c1::answer::hint}}."
+ (let* ((text "{{c1::Penicillin::antibiotic}} is a beta-lactam")
+ (contents (gnosis-cloze-extract-contents text))
+ (answers (gnosis-cloze-extract-answers contents))
+ (hints (gnosis-cloze-extract-hints contents))
+ (clean (gnosis-cloze-remove-tags text)))
+ (should (equal answers '(("Penicillin"))))
+ (should (equal hints '(("antibiotic"))))
+ (should (string= clean "Penicillin is a beta-lactam"))
+ (should (gnosis-cloze-check clean (car answers)))))
+
+(ert-deftest gnosis-test-cloze-pipeline-anki-mc-options ()
+ "Full pipeline: hint with multiple choice options."
+ (let* ((text "{{c1::rapid::rapid or slow}}-onset food poisoning")
+ (contents (gnosis-cloze-extract-contents text))
+ (answers (gnosis-cloze-extract-answers contents))
+ (hints (gnosis-cloze-extract-hints contents))
+ (clean (gnosis-cloze-remove-tags text)))
+ (should (equal answers '(("rapid"))))
+ (should (equal hints '(("rapid or slow"))))
+ (should (string= clean "rapid-onset food poisoning"))
+ (should (gnosis-cloze-check clean (car answers)))))
+
+(ert-deftest gnosis-test-cloze-pipeline-multi-group ()
+ "Full pipeline: two cX groups, one with hint."
+ (let* ((text "{{c1::Enterotoxins::Which exotoxin}} from {{c2::Staph
aureus}}")
+ (contents (gnosis-cloze-extract-contents text))
+ (answers (gnosis-cloze-extract-answers contents))
+ (hints (gnosis-cloze-extract-hints contents))
+ (clean (gnosis-cloze-remove-tags text)))
+ (should (= (length answers) 2))
+ (should (equal (nth 0 answers) '("Enterotoxins")))
+ (should (equal (nth 1 answers) '("Staph aureus")))
+ (should (equal (nth 0 hints) '("Which exotoxin")))
+ (should (equal (nth 1 hints) '(nil)))
+ (should (string= clean "Enterotoxins from Staph aureus"))
+ (should (gnosis-cloze-check clean (nth 0 answers)))
+ (should (gnosis-cloze-check clean (nth 1 answers)))))
+
+(ert-deftest gnosis-test-cloze-pipeline-same-group-with-hints ()
+ "Full pipeline: same cX group, multiple clozes each with hint."
+ (let* ((text "associated with {{c2::cleft lip::face}} and {{c2::cleft
palate::face}}")
+ (contents (gnosis-cloze-extract-contents text))
+ (answers (gnosis-cloze-extract-answers contents))
+ (hints (gnosis-cloze-extract-hints contents))
+ (clean (gnosis-cloze-remove-tags text)))
+ (should (= (length answers) 1))
+ (should (equal (car answers) '("cleft lip" "cleft palate")))
+ (should (equal (car hints) '("face" "face")))
+ (should (string= clean "associated with cleft lip and cleft palate"))
+ (should (gnosis-cloze-check clean (car answers)))))
+
+;; ──────────────────────────────────────────────────────────
+;; Import: cloze with Anki syntax + nil answer → auto-extract
+;; ──────────────────────────────────────────────────────────
+
+(defvar gnosis-test--db-file nil)
+
+(defmacro gnosis-test-with-db (&rest body)
+ "Run BODY with a fresh temporary gnosis database."
+ (declare (indent 0) (debug t))
+ `(let* ((gnosis-test--db-file (make-temp-file "gnosis-test-" nil ".db"))
+ (gnosis-db (emacsql-sqlite-open gnosis-test--db-file))
+ (gnosis--id-cache nil))
+ (unwind-protect
+ (progn
+ (emacsql-with-transaction gnosis-db
+ (pcase-dolist (`(,table ,schema) gnosis-db--schemata)
+ (emacsql gnosis-db [:create-table $i1 $S2] table schema)))
+ ,@body)
+ (emacsql-close gnosis-db)
+ (delete-file gnosis-test--db-file))))
+
+(ert-deftest gnosis-test-import-cloze-anki-syntax ()
+ "Importing a cloze thema with {{cN::answer::hint}} and nil answer
auto-extracts."
+ (gnosis-test-with-db
+ (let* ((deck-id (let ((id (+ (random 90000) 10000)))
+ (gnosis--insert-into 'decks `([,id "anki-test"]))
+ id))
+ (export-file (concat (make-temp-file "gnosis-cloze-import-")
".org")))
+ (unwind-protect
+ (progn
+ ;; Write an org file with Anki-like cloze syntax and empty answer
+ (with-temp-file export-file
+ (insert "#+DECK: anki-test\n")
+ (insert "#+THEMATA: 1\n\n")
+ (insert "* Thema :test:\n")
+ (insert ":PROPERTIES:\n")
+ (insert ":GNOSIS_ID: NEW\n")
+ (insert ":GNOSIS_TYPE: cloze\n")
+ (insert ":END:\n")
+ (insert "** Keimenon\n")
+ (insert "{{c1::Penicillin::antibiotic}} is a beta-lactam\n\n")
+ (insert "** Hypothesis\n\n")
+ (insert "** Answer\n\n")
+ (insert "** Parathema\n")
+ (insert "A common drug.\n"))
+ ;; Import (stub y-or-n-p for existing deck prompt)
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) t)))
+ (gnosis-import-deck export-file))
+ ;; Should have created 1 thema (1 cX group)
+ (let ((themata (gnosis-select 'id 'themata nil t)))
+ (should (= 1 (length themata))))
+ ;; Answer should be extracted as ("Penicillin")
+ (let* ((id (car (gnosis-select 'id 'themata nil t)))
+ (answer (gnosis-get 'answer 'themata `(= id ,id)))
+ (keimenon (gnosis-get 'keimenon 'themata `(= id ,id))))
+ (should (member "Penicillin" answer))
+ ;; Keimenon should have tags removed
+ (should (string= keimenon "Penicillin is a beta-lactam"))
+ ;; Hint should be extracted
+ (let ((hypothesis (gnosis-get 'hypothesis 'themata `(= id ,id))))
+ (should (member "antibiotic" hypothesis)))))
+ (when (file-exists-p export-file)
+ (delete-file export-file))))))
+
+(ert-deftest gnosis-test-import-cloze-anki-multi-group ()
+ "Importing cloze with multiple cX groups creates multiple themata."
+ (gnosis-test-with-db
+ (let* ((deck-id (let ((id (+ (random 90000) 10000)))
+ (gnosis--insert-into 'decks `([,id "multi-cloze"]))
+ id))
+ (export-file (concat (make-temp-file "gnosis-multi-cloze-")
".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file export-file
+ (insert "#+DECK: multi-cloze\n")
+ (insert "#+THEMATA: 1\n\n")
+ (insert "* Thema :test:\n")
+ (insert ":PROPERTIES:\n")
+ (insert ":GNOSIS_ID: NEW\n")
+ (insert ":GNOSIS_TYPE: cloze\n")
+ (insert ":END:\n")
+ (insert "** Keimenon\n")
+ (insert "{{c1::Toxin}} from {{c2::Staph aureus}} causes
disease\n\n")
+ (insert "** Hypothesis\n\n")
+ (insert "** Answer\n\n")
+ (insert "** Parathema\n"))
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) t)))
+ (gnosis-import-deck export-file))
+ ;; Should create 2 themata (c1 and c2)
+ (let ((themata (gnosis-select 'id 'themata nil t)))
+ (should (= 2 (length themata))))
+ ;; Both should share the same clean keimenon
+ (let ((all-keimenon (gnosis-select 'keimenon 'themata nil t)))
+ (should (cl-every (lambda (k)
+ (string= k "Toxin from Staph aureus causes
disease"))
+ all-keimenon))))
+ (when (file-exists-p export-file)
+ (delete-file export-file))))))
+
+;; ──────────────────────────────────────────────────────────
+;; Chunked import helpers
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-split-chunks-basic ()
+ "Splitting text into chunks groups headings correctly."
+ (let* ((text (concat "#+DECK: test\n\n"
+ "* Thema\nA\n* Thema\nB\n* Thema\nC\n"
+ "* Thema\nD\n* Thema\nE\n"))
+ (chunks (gnosis--import-split-chunks text 2)))
+ (should (= 3 (length chunks)))
+ ;; First chunk has 2 headings
+ (should (= 2 (with-temp-buffer
+ (insert (nth 0 chunks))
+ (count-matches "^\\* Thema" (point-min) (point-max)))))
+ ;; Last chunk has 1 heading
+ (should (= 1 (with-temp-buffer
+ (insert (nth 2 chunks))
+ (count-matches "^\\* Thema" (point-min) (point-max)))))))
+
+(ert-deftest gnosis-test-split-chunks-single ()
+ "All entries in one chunk when chunk-size >= total."
+ (let* ((text "* Thema\nA\n* Thema\nB\n")
+ (chunks (gnosis--import-split-chunks text 100)))
+ (should (= 1 (length chunks)))))
+
+(ert-deftest gnosis-test-import-chunk-basic ()
+ "gnosis--import-chunk processes a chunk and inserts themata."
+ (gnosis-test-with-db
+ (let* ((deck-id (let ((id (+ (random 90000) 10000)))
+ (gnosis--insert-into 'decks `([,id "chunk-test"]))
+ id))
+ (id-cache (make-hash-table :test 'equal))
+ (header "#+DECK: chunk-test")
+ (chunk (concat "* Thema\n"
+ ":PROPERTIES:\n"
+ ":GNOSIS_ID: NEW\n"
+ ":GNOSIS_TYPE: basic\n"
+ ":END:\n"
+ "** Keimenon\nWhat is 2+2?\n\n"
+ "** Hypothesis\n\n"
+ "** Answer\n- 4\n\n"
+ "** Parathema\n\n"
+ "* Thema\n"
+ ":PROPERTIES:\n"
+ ":GNOSIS_ID: NEW\n"
+ ":GNOSIS_TYPE: cloze\n"
+ ":END:\n"
+ "** Keimenon\n{{c1::Emacs}} is a text editor\n\n"
+ "** Hypothesis\n\n"
+ "** Answer\n\n"
+ "** Parathema\n"))
+ (errors (gnosis--import-chunk header chunk deck-id id-cache)))
+ (should (null errors))
+ ;; Basic thema + cloze thema (1 cX group) = 2 themata
+ (should (= 2 (length (gnosis-select 'id 'themata nil t))))
+ ;; IDs should be registered in cache
+ (should (= 2 (hash-table-count id-cache))))))
+
+(ert-run-tests-batch-and-exit)