branch: elpa/gnosis
commit 6f74c26da26efaa97a573611804b183cf04e8520
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
[Refactor] Optimize deck export/import and add export options.
- Export: batch-fetch themata in 2 queries instead of 2*N,
insert tags/properties as plain text instead of O(n²) buffer scans.
- Import: wrap in single transaction with ID cache
- Add option to exclude suspended themata from export
- Add #+THEMATA header with exported count
- Report line numbers on failed thema imports
- Confirm before importing into existing deck
- Add export/import test suite
---
Makefile | 14 +-
gnosis.el | 179 ++++++++++++++-----
tests/gnosis-test-export-import.el | 355 +++++++++++++++++++++++++++++++++++++
3 files changed, 497 insertions(+), 51 deletions(-)
diff --git a/Makefile b/Makefile
index 1836447ef4..0b39205a49 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ EMACS = emacs
ORG := doc/gnosis.org
TEXI := doc/gnosis.texi
INFO := doc/gnosis.info
-TEST_FILE := tests/gnosis-test-algorithm.el
+TEST_FILES := tests/gnosis-test-algorithm.el tests/gnosis-test-export-import.el
all: doc
@@ -20,11 +20,13 @@ doc: $(ORG)
test:
rm -f *.elc
- $(EMACS) --batch \
- -q \
- --eval "(add-to-list 'load-path \"$(shell pwd)\")" \
- --load $(TEST_FILE) \
- --eval "(ert-run-tests-batch-and-exit)"
+ @for f in $(TEST_FILES); do \
+ echo "Running $$f..."; \
+ $(EMACS) --batch \
+ -q \
+ --eval "(add-to-list 'load-path \"$(shell pwd)\")" \
+ --load $$f; \
+ done
clean:
rm -f $(TEXI) $(INFO) *.elc *-pkg.el*
diff --git a/gnosis.el b/gnosis.el
index e41ca08f82..dca31a4e81 100644
--- a/gnosis.el
+++ b/gnosis.el
@@ -221,6 +221,10 @@ This is set automatically based on buffer type:
(defvar gnosis-review-editing-p nil
"Boolean value to check if user is currently in a review edit.")
+(defvar gnosis--id-cache nil
+ "Hash table of existing thema IDs, bound during batch import.
+When non-nil, `gnosis-generate-id' and `gnosis-update-thema' use this
+for O(1) lookups instead of querying the database per thema.")
(defun gnosis-select (value table &optional restrictions flatten)
"Select VALUE from TABLE, optionally with RESTRICTIONS.
@@ -694,16 +698,21 @@ When called with a prefix, unsuspends all themata in
deck."
"Generate a unique gnosis ID.
Default to generating a thema id, when DECK-P is t generates a deck id.
+When `gnosis--id-cache' is bound, uses hash table lookup instead of DB query.
LENGTH: length of id, default to a random number between 10-15."
(let* ((length (or length (+ (random 5) 10)))
(max-val (expt 10 length))
(min-val (expt 10 (1- length)))
(id (+ (random (- max-val min-val)) min-val))
- (current-ids (if deck-p (gnosis-select 'id 'decks nil t)
- (gnosis-select 'id 'themata nil t))))
- (if (member id current-ids)
+ (exists (if (and gnosis--id-cache (not deck-p))
+ (gethash id gnosis--id-cache)
+ (member id (if deck-p (gnosis-select 'id 'decks nil t)
+ (gnosis-select 'id 'themata nil t))))))
+ (if exists
(gnosis-generate-id length)
+ (when gnosis--id-cache
+ (puthash id t gnosis--id-cache))
id)))
(defun gnosis-mcq-answer (id)
@@ -1536,9 +1545,12 @@ LINKS: List of id links."
&optional deck-id type)
"Update thema entry for ID.
-If gnosis ID does not exist, create it anew."
+If gnosis ID does not exist, create it anew.
+When `gnosis--id-cache' is bound, uses hash table for existence check."
(let ((id (if (stringp id) (string-to-number id) id)))
- (if (member id (gnosis-select 'id 'themata nil t))
+ (if (if gnosis--id-cache
+ (gethash id gnosis--id-cache)
+ (member id (gnosis-select 'id 'themata nil t)))
(emacsql-with-transaction gnosis-db
(gnosis-update 'themata `(= keimenon ,keimenon) `(= id ,id))
(gnosis-update 'themata `(= hypothesis ',hypothesis) `(= id ,id))
@@ -1763,14 +1775,6 @@ LINKS: list of strings."
(let ((inhibit-read-only t))
(insert " "))))
-(defun gnosis-export-make-read-only (&rest values)
- "Make the provided VALUES read-only in the whole buffer."
- (goto-char (point-min))
- (dolist (value values)
- (while (search-forward value nil t)
- (put-text-property (match-beginning 0) (match-end 0) 'read-only t)))
- (goto-char (point-min)))
-
(cl-defun gnosis-export--insert-thema (id type &optional keimenon hypothesis
answer parathema tags example)
"Insert thema for thema ID.
@@ -1786,15 +1790,15 @@ EXAMPLE: Boolean value, if non-nil do not add
properties for thema."
("** Hypothesis" . ,hypothesis)
("** Answer" . ,answer)
("** Parathema" . ,parathema))))
+ (goto-char (point-max))
(insert "\n* Thema")
- (org-set-tags tags)
+ (when tags
+ (insert " :" (mapconcat #'identity tags ":") ":"))
+ (insert "\n")
(unless example
- (org-set-property "GNOSIS_ID" id)
- (org-set-property "GNOSIS_TYPE" type)
- (gnosis-export-make-read-only ":PROPERTIES:"
- (format "GNOSIS_ID: %s" id)
- (format "GNOSIS_TYPE: %s" type)
- ":END:"))
+ (let ((start (point)))
+ (insert ":PROPERTIES:\n:GNOSIS_ID: " id "\n:GNOSIS_TYPE: " type
"\n:END:\n")
+ (add-text-properties start (point) '(read-only t))))
(dolist (comp components)
(goto-char (point-max))
(gnosis-export--insert-read-only (car comp))
@@ -1823,7 +1827,9 @@ Split content of Hypothesis and Answer headings using
SEPARATOR."
(gnosis-type (org-element-property :GNOSIS_TYPE headline))
(tags (org-element-property :tags headline)))
(when (and (= level 1) gnosis-id gnosis-type)
- (let (entry)
+ (let ((line (line-number-at-pos
+ (org-element-property :begin headline)))
+ entry)
(push gnosis-id entry)
(push gnosis-type entry)
(dolist (child (org-element-contents headline))
@@ -1846,6 +1852,7 @@ Split content of Hypothesis and Answer headings using
SEPARATOR."
(t child-text))))
(push processed-text entry))))
(push tags entry)
+ (push line entry)
(push (nreverse entry) results)))))
nil nil)
results))
@@ -1876,12 +1883,17 @@ generate new thema id."
(nth 5 thema-data)
(nth 4 thema-data))))))
-(defun gnosis-export-deck (&optional deck filename new-p)
- "Export contents of DECK to FILENAME."
+(defun gnosis-export-deck (&optional deck filename new-p include-suspended)
+ "Export contents of DECK to FILENAME.
+
+When NEW-P, replace thema IDs with NEW for fresh import.
+When INCLUDE-SUSPENDED, also export suspended themata."
(interactive (list (gnosis--get-deck-id)
(read-file-name "Export to file: ")
- (not (y-or-n-p "Export with current thema ids? "))))
- (let* ((deck-name (gnosis--get-deck-name deck))
+ (not (y-or-n-p "Export with current thema ids? "))
+ (y-or-n-p "Include suspended themata? ")))
+ (let* ((gc-cons-threshold most-positive-fixnum)
+ (deck-name (gnosis--get-deck-name deck))
(filename (if (file-directory-p filename)
(expand-file-name deck-name filename)
filename)))
@@ -1891,15 +1903,57 @@ generate new thema id."
(let ((inhibit-read-only t))
(org-mode)
(erase-buffer)
- (insert (format "#+DECK: %s\n\n" deck-name))
- (let ((thema-ids (gnosis-select 'id 'themata `(= deck-id ,deck))))
- (gnosis-export-themata thema-ids new-p)
+ (insert (format "#+DECK: %s\n" deck-name))
+ ;; Batch-fetch: 2 queries instead of 2*N
+ (let* ((all-themata (emacsql gnosis-db
+ [:select [id type keimenon hypothesis answer tags]
+ :from themata :where (= deck-id $s1)] deck))
+ (all-ids (mapcar #'car all-themata))
+ (suspended-ids (when (and all-ids (not include-suspended))
+ (mapcar #'car
+ (emacsql gnosis-db
+ [:select id :from review-log
+ :where (and (in id $v1) (= suspend
1))]
+ (vconcat all-ids)))))
+ (all-themata (if suspended-ids
+ (cl-remove-if (lambda (row)
+ (member (car row)
suspended-ids))
+ all-themata)
+ all-themata))
+ (all-ids (mapcar #'car all-themata))
+ (all-extras (when all-ids
+ (emacsql gnosis-db
+ [:select [id parathema] :from extras
+ :where (in id $v1)] (vconcat all-ids))))
+ (extras-ht (let ((ht (make-hash-table :test 'equal
+ :size (length all-ids))))
+ (dolist (row all-extras ht)
+ (puthash (car row) (cadr row) ht)))))
+ (insert (format "#+THEMATA: %d\n\n" (length all-themata)))
+ (dolist (row all-themata)
+ (let* ((id (nth 0 row))
+ (type (nth 1 row))
+ (hypothesis (nth 3 row))
+ (answer (nth 4 row))
+ (tags (nth 5 row))
+ (parathema (gethash id extras-ht "")))
+ (gnosis-export--insert-thema
+ (if new-p "NEW" (number-to-string id))
+ type
+ (nth 2 row)
+ (concat (string-remove-prefix "\n" gnosis-export-separator)
+ (mapconcat #'identity hypothesis
gnosis-export-separator))
+ (concat (string-remove-prefix "\n" gnosis-export-separator)
+ (mapconcat #'identity answer gnosis-export-separator))
+ parathema
+ tags)))
(when filename
(write-file filename)
(message "Exported deck to %s" filename)))))))
(defun gnosis-save-thema (thema deck)
- "Save THEMA for DECK."
+ "Save THEMA for DECK.
+Returns nil on success, or an error message string on failure."
(let* ((id (nth 0 thema))
(type (nth 1 thema))
(keimenon (nth 2 thema))
@@ -1907,45 +1961,80 @@ generate new thema id."
(answer (nth 4 thema))
(parathema (or (nth 5 thema) ""))
(tags (nth 6 thema))
+ (line (nth 7 thema))
(links (append (gnosis-extract-id-links parathema)
(gnosis-extract-id-links keimenon)))
(thema-func (cdr (assoc (downcase type)
(mapcar (lambda (pair) (cons (downcase (car
pair))
(cdr pair)))
gnosis-thema-types)))))
- (funcall thema-func id deck type keimenon hypothesis
- answer parathema tags 0 links)))
+ (condition-case err
+ (progn
+ (funcall thema-func id deck type keimenon hypothesis
+ answer parathema tags 0 links)
+ nil)
+ (error (format "Line %s (id:%s): %s" (or line "?") id
+ (error-message-string err))))))
(defun gnosis-save ()
"Save themata in current buffer."
(interactive nil gnosis-edit-mode)
- (let ((themata (gnosis-export-parse-themata))
- (deck (gnosis--get-deck-id (gnosis-export-parse--deck-name))))
- (cl-loop for thema in themata
- do (gnosis-save-thema thema deck))
- (gnosis-edit-quit)))
+ (let* ((gc-cons-threshold most-positive-fixnum)
+ (themata (gnosis-export-parse-themata))
+ (deck (gnosis--get-deck-id (gnosis-export-parse--deck-name)))
+ (gnosis--id-cache (let ((ht (make-hash-table :test 'equal)))
+ (dolist (id (gnosis-select 'id 'themata nil t) ht)
+ (puthash id t ht))))
+ (errors nil))
+ (emacsql-with-transaction gnosis-db
+ (cl-loop for thema in themata
+ for err = (gnosis-save-thema thema deck)
+ when err do (push err errors)))
+ (if errors
+ (user-error "Failed to import %d thema(ta):\n%s"
+ (length errors) (mapconcat #'identity (nreverse errors)
"\n"))
+ (gnosis-edit-quit))))
;;;###autoload
(defun gnosis-save-deck (deck-name)
- "Save themata for deck with DECK-NAME."
+ "Save themata for deck with DECK-NAME.
+
+If a deck with DECK-NAME already exists, prompt for confirmation
+before importing into it."
(interactive
(progn
(unless (eq major-mode 'org-mode)
(user-error "This function can only be used in org-mode buffers"))
(list (read-string "Deck name: " (gnosis-export-parse--deck-name)))))
- (let ((themata (gnosis-export-parse-themata))
- (deck (gnosis-get-deck-id deck-name)))
- (cl-loop for thema in themata
- do (gnosis-save-thema thema deck))))
+ (when (and (gnosis-get 'id 'decks `(= name ,deck-name))
+ (not (y-or-n-p (format "Deck '%s' already exists. Import into it? "
+ deck-name))))
+ (user-error "Aborted"))
+ (let* ((gc-cons-threshold most-positive-fixnum)
+ (themata (gnosis-export-parse-themata))
+ (deck (gnosis-get-deck-id deck-name))
+ (gnosis--id-cache (let ((ht (make-hash-table :test 'equal)))
+ (dolist (id (gnosis-select 'id 'themata nil t) ht)
+ (puthash id t ht))))
+ (errors nil))
+ (emacsql-with-transaction gnosis-db
+ (cl-loop for thema in themata
+ for err = (gnosis-save-thema thema deck)
+ when err do (push err errors)))
+ (if errors
+ (user-error "Failed to import %d thema(ta):\n%s"
+ (length errors) (mapconcat #'identity (nreverse errors)
"\n"))
+ (message "Imported %d themata for deck '%s'" (length themata)
deck-name))))
;;;###autoload
(defun gnosis-import-deck (file)
"Save gnosis deck from FILE."
(interactive "fFile: ")
- (with-temp-buffer
- (insert-file-contents file)
- (org-mode)
- (gnosis-save-deck (gnosis-export-parse--deck-name))))
+ (let ((gc-cons-threshold most-positive-fixnum))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (gnosis-save-deck (gnosis-export-parse--deck-name)))))
;;;###autoload
(defun gnosis-add-thema (deck type &optional keimenon hypothesis
diff --git a/tests/gnosis-test-export-import.el
b/tests/gnosis-test-export-import.el
new file mode 100644
index 0000000000..3cb640032f
--- /dev/null
+++ b/tests/gnosis-test-export-import.el
@@ -0,0 +1,355 @@
+;;; gnosis-test-export-import.el --- Export/import tests -*- lexical-binding:
t; -*-
+
+;; Copyright (C) 2023-2026 Thanos Apollo
+
+;; Author: Thanos Apollo <[email protected]>
+
+;;; Commentary:
+
+;; Tests for deck export and import functionality.
+;; Uses a temporary SQLite database so the user's real DB is untouched.
+
+;;; 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))
+
+;; ──────────────────────────────────────────────────────────
+;; Test helpers
+;; ──────────────────────────────────────────────────────────
+
+(defvar gnosis-test--db-file nil
+ "Path to temporary test database file.")
+
+(defmacro gnosis-test-with-db (&rest body)
+ "Run BODY with a fresh temporary gnosis database.
+Rebinds `gnosis-db' and initialises the schema."
+ (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
+ ;; Create all tables
+ (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))))
+
+(defun gnosis-test--add-deck (name)
+ "Add a deck with NAME to the test DB. Return its id."
+ (let ((id (+ (random 90000) 10000)))
+ (gnosis--insert-into 'decks `([,id ,name]))
+ id))
+
+(defun gnosis-test--add-basic-thema (deck-id keimenon answer
+ &optional tags parathema thema-id suspend)
+ "Insert a basic thema into the test DB. Return its id.
+DECK-ID, KEIMENON, ANSWER are required.
+TAGS defaults to (\"test\"), PARATHEMA to \"\".
+SUSPEND: 1 to suspend, 0 or nil for active."
+ (let* ((id (or thema-id (gnosis-generate-id)))
+ (tags (or tags '("test")))
+ (parathema (or parathema ""))
+ (suspend (or suspend 0))
+ (hypothesis '(""))
+ (answer (if (listp answer) answer (list answer))))
+ (emacsql-with-transaction gnosis-db
+ (gnosis--insert-into 'themata `([,id "basic" ,keimenon ,hypothesis
+ ,answer ,tags ,deck-id]))
+ (gnosis--insert-into 'review `([,id ,gnosis-algorithm-gnosis-value
+ ,gnosis-algorithm-amnesia-value]))
+ (gnosis--insert-into 'review-log `([,id ,(gnosis-algorithm-date)
+ ,(gnosis-algorithm-date) 0 0 0 0
+ ,suspend 0]))
+ (gnosis--insert-into 'extras `([,id ,parathema ""])))
+ id))
+
+(defun gnosis-test--kill-export-buffer (deck-name)
+ "Kill the export buffer for DECK-NAME if it exists."
+ (let ((buf (get-buffer (format "EXPORT: %s" deck-name))))
+ (when buf (kill-buffer buf))))
+
+;; ──────────────────────────────────────────────────────────
+;; Export tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-export-deck-basic ()
+ "Export a deck and verify the org buffer content."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "What is 2+2?" "4"
+ '("math" "basic")))
+ (id2 (gnosis-test--add-basic-thema deck-id "Capital of Greece?"
"Athens"
+ '("geo")))
+ (export-file (concat (make-temp-file "gnosis-export-") ".org")))
+ (unwind-protect
+ (progn
+ (gnosis-export-deck deck-id export-file nil)
+ (should (file-exists-p export-file))
+ (with-temp-buffer
+ (insert-file-contents export-file)
+ (let ((content (buffer-string)))
+ ;; Deck header present
+ (should (string-search "#+DECK: test-deck" content))
+ ;; Themata count in header
+ (should (string-search "#+THEMATA: 2" content))
+ ;; Both themata exported
+ (should (string-search (number-to-string id1) content))
+ (should (string-search (number-to-string id2) content))
+ ;; Tags in org format
+ (should (string-search ":math:basic:" content))
+ (should (string-search ":geo:" content))
+ ;; Properties present
+ (should (string-search ":GNOSIS_ID:" content))
+ (should (string-search ":GNOSIS_TYPE: basic" content))
+ ;; Content present
+ (should (string-search "What is 2+2?" content))
+ (should (string-search "Capital of Greece?" content))
+ (should (string-search "4" content))
+ (should (string-search "Athens" content)))))
+ (when (file-exists-p export-file)
+ (delete-file export-file))
+ (gnosis-test--kill-export-buffer "test-deck")))))
+
+(ert-deftest gnosis-test-export-deck-new-ids ()
+ "Export with new-p replaces IDs with NEW."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "new-id-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1"))
+ (export-file (concat (make-temp-file "gnosis-export-new-") ".org")))
+ (unwind-protect
+ (progn
+ (gnosis-export-deck deck-id export-file t)
+ (with-temp-buffer
+ (insert-file-contents export-file)
+ (let ((content (buffer-string)))
+ (should (string-search ":GNOSIS_ID: NEW" content))
+ (should-not (string-search
+ (format ":GNOSIS_ID: %d" id1) content)))))
+ (when (file-exists-p export-file)
+ (delete-file export-file))
+ (gnosis-test--kill-export-buffer "new-id-deck")))))
+
+(ert-deftest gnosis-test-export-empty-deck ()
+ "Exporting an empty deck produces a file with just the header."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "empty-deck"))
+ (export-file (concat (make-temp-file "gnosis-export-empty-")
".org")))
+ (unwind-protect
+ (progn
+ (gnosis-export-deck deck-id export-file nil)
+ (with-temp-buffer
+ (insert-file-contents export-file)
+ (let ((content (buffer-string)))
+ (should (string-search "#+DECK: empty-deck" content))
+ (should-not (string-search "* Thema" content)))))
+ (when (file-exists-p export-file)
+ (delete-file export-file))
+ (gnosis-test--kill-export-buffer "empty-deck")))))
+
+(ert-deftest gnosis-test-export-excludes-suspended ()
+ "Export without include-suspended skips suspended themata."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "susp-deck"))
+ (_id1 (gnosis-test--add-basic-thema deck-id "Active Q" "A1" '("a")))
+ (_id2 (gnosis-test--add-basic-thema deck-id "Suspended Q" "A2"
+ '("s") nil nil 1))
+ (export-file (concat (make-temp-file "gnosis-export-susp-")
".org")))
+ (unwind-protect
+ (progn
+ ;; Export without suspended
+ (gnosis-export-deck deck-id export-file nil nil)
+ (with-temp-buffer
+ (insert-file-contents export-file)
+ (let ((content (buffer-string)))
+ (should (string-search "#+THEMATA: 1" content))
+ (should (string-search "Active Q" content))
+ (should-not (string-search "Suspended Q" content)))))
+ (when (file-exists-p export-file)
+ (delete-file export-file))
+ (gnosis-test--kill-export-buffer "susp-deck")))))
+
+(ert-deftest gnosis-test-export-includes-suspended ()
+ "Export with include-suspended includes all themata."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "susp-deck2"))
+ (_id1 (gnosis-test--add-basic-thema deck-id "Active Q" "A1" '("a")))
+ (_id2 (gnosis-test--add-basic-thema deck-id "Suspended Q" "A2"
+ '("s") nil nil 1))
+ (export-file (concat (make-temp-file "gnosis-export-susp2-")
".org")))
+ (unwind-protect
+ (progn
+ ;; Export with suspended
+ (gnosis-export-deck deck-id export-file nil t)
+ (with-temp-buffer
+ (insert-file-contents export-file)
+ (let ((content (buffer-string)))
+ (should (string-search "#+THEMATA: 2" content))
+ (should (string-search "Active Q" content))
+ (should (string-search "Suspended Q" content)))))
+ (when (file-exists-p export-file)
+ (delete-file export-file))
+ (gnosis-test--kill-export-buffer "susp-deck2")))))
+
+;; ──────────────────────────────────────────────────────────
+;; Import tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-import-creates-deck ()
+ "Importing a file creates the deck if it doesn't exist."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "import-deck"))
+ (_id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1" '("tag1")))
+ (export-file (concat (make-temp-file "gnosis-import-") ".org")))
+ (unwind-protect
+ (progn
+ (gnosis-export-deck deck-id export-file t)
+ (gnosis-test--kill-export-buffer "import-deck")
+ ;; Import into a fresh DB to check deck creation
+ (let* ((db-file2 (make-temp-file "gnosis-test2-" nil ".db"))
+ (gnosis-db (emacsql-sqlite-open db-file2))
+ (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)))
+ ;; Deck should not exist yet
+ (should-not (gnosis-get 'id 'decks '(= name
"import-deck")))
+ (gnosis-import-deck export-file)
+ ;; Deck should now exist
+ (should (gnosis-get 'id 'decks '(= name "import-deck")))
+ ;; Thema should exist
+ (should (= 1 (length (gnosis-select 'id 'themata nil t)))))
+ (emacsql-close gnosis-db)
+ (delete-file db-file2))))
+ (when (file-exists-p export-file)
+ (delete-file export-file))))))
+
+(ert-deftest gnosis-test-import-roundtrip ()
+ "Export then import: thema count and content survive the roundtrip."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "roundtrip"))
+ (_id1 (gnosis-test--add-basic-thema deck-id "What is Emacs?" "A
text editor"
+ '("emacs" "editor")))
+ (_id2 (gnosis-test--add-basic-thema deck-id "What is Lisp?" "A
language"
+ '("lisp") "See SICP"))
+ (_id3 (gnosis-test--add-basic-thema deck-id "What is org?" "A mode"
+ '("org")))
+ (export-file (concat (make-temp-file "gnosis-roundtrip-") ".org")))
+ (unwind-protect
+ (progn
+ (gnosis-export-deck deck-id export-file t)
+ (gnosis-test--kill-export-buffer "roundtrip")
+ ;; Import into a fresh DB
+ (let* ((db-file2 (make-temp-file "gnosis-rt2-" nil ".db"))
+ (gnosis-db (emacsql-sqlite-open db-file2))
+ (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)))
+ (gnosis-import-deck export-file)
+ ;; 3 themata imported
+ (should (= 3 (length (gnosis-select 'id 'themata nil t))))
+ ;; Content preserved
+ (let ((all-keimenon (gnosis-select 'keimenon 'themata nil
t)))
+ (should (member "What is Emacs?" all-keimenon))
+ (should (member "What is Lisp?" all-keimenon))
+ (should (member "What is org?" all-keimenon)))
+ ;; Parathema preserved
+ (let* ((thema-id (gnosis-get 'id 'themata
+ '(= keimenon "What is
Lisp?")))
+ (parathema (gnosis-get 'parathema 'extras
+ `(= id ,thema-id))))
+ (should (string-search "See SICP" parathema)))
+ ;; Tags preserved
+ (let* ((thema-id (gnosis-get 'id 'themata
+ '(= keimenon "What is
Emacs?")))
+ (tags (gnosis-get 'tags 'themata `(= id
,thema-id))))
+ (should (member "emacs" tags))
+ (should (member "editor" tags))))
+ (emacsql-close gnosis-db)
+ (delete-file db-file2))))
+ (when (file-exists-p export-file)
+ (delete-file export-file))))))
+
+(ert-deftest gnosis-test-import-updates-existing-thema ()
+ "Importing with existing IDs updates themata rather than duplicating."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "update-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Old question" "Old
answer"
+ '("old")))
+ (export-file (concat (make-temp-file "gnosis-update-") ".org")))
+ (unwind-protect
+ (progn
+ ;; Export with real IDs (not NEW)
+ (gnosis-export-deck deck-id export-file nil)
+ (gnosis-test--kill-export-buffer "update-deck")
+ ;; Modify the exported file: change the answer
+ (with-temp-file export-file
+ (insert-file-contents export-file)
+ (goto-char (point-min))
+ (when (search-forward "Old answer" nil t)
+ (replace-match "New answer")))
+ ;; Import back — should update, not duplicate
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) t)))
+ (gnosis-import-deck export-file))
+ ;; Still just 1 thema
+ (should (= 1 (length (gnosis-select 'id 'themata nil t))))
+ ;; Answer updated
+ (let ((answer (gnosis-get 'answer 'themata `(= id ,id1))))
+ (should (member "New answer" answer))))
+ (when (file-exists-p export-file)
+ (delete-file export-file))))))
+
+(ert-deftest gnosis-test-import-confirms-existing-deck ()
+ "Importing into an existing deck prompts; declining aborts."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "confirm-deck"))
+ (_id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1"))
+ (export-file (concat (make-temp-file "gnosis-confirm-") ".org")))
+ (unwind-protect
+ (progn
+ (gnosis-export-deck deck-id export-file nil)
+ (gnosis-test--kill-export-buffer "confirm-deck")
+ ;; Decline the prompt — should abort
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil)))
+ (should-error (gnosis-import-deck export-file)
+ :type 'user-error)))
+ (when (file-exists-p export-file)
+ (delete-file export-file))))))
+
+;; ──────────────────────────────────────────────────────────
+;; ID cache tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-id-cache-generates-unique ()
+ "gnosis-generate-id with cache produces unique IDs and registers them."
+ (let ((gnosis--id-cache (make-hash-table :test 'equal)))
+ ;; Seed cache with one known ID
+ (puthash 12345678901 t gnosis--id-cache)
+ (let ((new-id (gnosis-generate-id 11)))
+ ;; Should not collide
+ (should-not (= new-id 12345678901))
+ ;; Should be registered in cache
+ (should (gethash new-id gnosis--id-cache)))))
+
+(ert-deftest gnosis-test-id-cache-no-db-query ()
+ "gnosis-generate-id with cache works without a DB connection."
+ (let ((gnosis--id-cache (make-hash-table :test 'equal))
+ (gnosis-db nil))
+ ;; Should not error — cache means no DB query needed
+ (let ((id (gnosis-generate-id)))
+ (should (integerp id))
+ (should (gethash id gnosis--id-cache)))))
+
+(ert-run-tests-batch-and-exit)