branch: elpa/gnosis
commit 416dec79cee888695b213b26c4101cb7d7faaf54
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
tests: Add dashboard tests.
---
Makefile | 2 +-
tests/gnosis-test-dashboard.el | 451 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 452 insertions(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index 0b39205a49..9766e09f57 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ EMACS = emacs
ORG := doc/gnosis.org
TEXI := doc/gnosis.texi
INFO := doc/gnosis.info
-TEST_FILES := tests/gnosis-test-algorithm.el tests/gnosis-test-export-import.el
+TEST_FILES := tests/gnosis-test-algorithm.el
tests/gnosis-test-export-import.el tests/gnosis-test-dashboard.el
all: doc
diff --git a/tests/gnosis-test-dashboard.el b/tests/gnosis-test-dashboard.el
new file mode 100644
index 0000000000..b921063f7a
--- /dev/null
+++ b/tests/gnosis-test-dashboard.el
@@ -0,0 +1,451 @@
+;;; gnosis-test-dashboard.el --- Dashboard tests -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Free Software Foundation, Inc.
+
+;; Author: Thanos Apollo <[email protected]>
+
+;;; Commentary:
+
+;; Tests for gnosis-dashboard.el functionality.
+;; Uses a temporary SQLite database so the user's real DB is untouched.
+
+;;; Code:
+(require 'ert)
+(require 'gnosis)
+(require 'gnosis-dashboard)
+
+(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-dash-" 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))))
+
+(defmacro gnosis-test-with-dashboard-buffer (&rest body)
+ "Run BODY in a temporary dashboard buffer with tabulated-list-mode.
+Stubs `pop-to-buffer-same-window' so tests work in batch mode."
+ (declare (indent 0) (debug t))
+ `(let ((gnosis-dashboard-buffer-name "*Gnosis Dashboard Test*"))
+ (get-buffer-create gnosis-dashboard-buffer-name)
+ (cl-letf (((symbol-function 'pop-to-buffer-same-window)
+ (lambda (buf &rest _) (set-buffer (get-buffer-create buf)))))
+ (unwind-protect
+ (progn ,@body)
+ (when (get-buffer gnosis-dashboard-buffer-name)
+ (kill-buffer gnosis-dashboard-buffer-name))))))
+
+(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--add-activity (date total new)
+ "Insert an activity-log row for DATE with TOTAL and NEW counts."
+ (gnosis--insert-into 'activity-log `([,date ,total ,new])))
+
+(defun gnosis-test--add-link (thema-id node-id)
+ "Insert a link from THEMA-ID to NODE-ID string."
+ (gnosis--insert-into 'links `([,thema-id ,node-id])))
+
+(defun gnosis-test--setup-tags ()
+ "Populate tags table from existing themata in test DB."
+ (let ((tags (gnosis-get-tags--unique)))
+ (emacsql-with-transaction gnosis-db
+ (cl-loop for tag in tags
+ do (condition-case nil
+ (gnosis--insert-into 'tags `[,tag])
+ (error nil))))))
+
+;; ──────────────────────────────────────────────────────────
+;; Pure function tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-dashboard-streak-empty ()
+ "Streak with no dates returns \"0\"."
+ (should (equal (gnosis-dashboard--streak nil) "0")))
+
+(ert-deftest gnosis-test-dashboard-streak-yesterday ()
+ "Streak with only yesterday returns \"1\"."
+ (let ((dates (list (gnosis-algorithm-date -1))))
+ (should (equal (gnosis-dashboard--streak dates) "1"))))
+
+(ert-deftest gnosis-test-dashboard-streak-consecutive ()
+ "Streak with consecutive past days counts correctly."
+ (let ((dates (list (gnosis-algorithm-date -1)
+ (gnosis-algorithm-date -2)
+ (gnosis-algorithm-date -3))))
+ (should (equal (gnosis-dashboard--streak dates) "3"))))
+
+(ert-deftest gnosis-test-dashboard-streak-today-only ()
+ "Streak with only today returns \"1\"."
+ (let ((dates (list (gnosis-algorithm-date))))
+ (should (equal (gnosis-dashboard--streak dates) "1"))))
+
+(ert-deftest gnosis-test-dashboard-streak-today-plus-consecutive ()
+ "Streak includes today bonus on top of consecutive past days."
+ (let ((dates (list (gnosis-algorithm-date)
+ (gnosis-algorithm-date -1)
+ (gnosis-algorithm-date -2))))
+ (should (equal (gnosis-dashboard--streak dates) "3"))))
+
+(ert-deftest gnosis-test-dashboard-streak-gap ()
+ "A gap in dates stops the streak."
+ ;; Yesterday and 3-days-ago, but NOT 2-days-ago
+ (let ((dates (list (gnosis-algorithm-date -1)
+ (gnosis-algorithm-date -3))))
+ (should (equal (gnosis-dashboard--streak dates) "1"))))
+
+(ert-deftest gnosis-test-dashboard-sort-total-themata ()
+ "Sort entries by column index 1 (total themata count)."
+ (let ((entry-small '("tag-a" ["tag-a" "5"]))
+ (entry-large '("tag-b" ["tag-b" "20"])))
+ (should (gnosis-dashboard-sort-total-themata entry-small entry-large))
+ (should-not (gnosis-dashboard-sort-total-themata entry-large
entry-small))))
+
+;; ──────────────────────────────────────────────────────────
+;; Data-formatting function tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-dashboard-output-themata-basic ()
+ "Output-themata returns correctly formatted entries."
+ (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")))
+ (id2 (gnosis-test--add-basic-thema deck-id "Capital?" "Athens"
+ '("geo")))
+ (entries (gnosis-dashboard--output-themata (list id1 id2))))
+ ;; Two entries returned
+ (should (= (length entries) 2))
+ ;; Each entry is (id vector)
+ (let* ((e1 (cl-find id1 entries :key #'car))
+ (vec1 (cadr e1)))
+ (should e1)
+ ;; Keimenon is first field
+ (should (string-search "What is 2+2?" (aref vec1 0)))
+ ;; Type is "basic"
+ (should (equal (aref vec1 4) "basic"))
+ ;; Not suspended → "No"
+ (should (equal (aref vec1 5) "No"))))))
+
+(ert-deftest gnosis-test-dashboard-output-themata-suspended ()
+ "Suspended themata show \"Yes\" in suspend column."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Q?" "A" '("t") nil nil
1))
+ (entries (gnosis-dashboard--output-themata (list id1)))
+ (e1 (cl-find id1 entries :key #'car))
+ (vec1 (cadr e1)))
+ (should (equal (aref vec1 5) "Yes")))))
+
+(ert-deftest gnosis-test-dashboard-output-themata-list-tags ()
+ "List-valued tags are joined with commas."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Q?" "A"
+ '("math" "algebra")))
+ (entries (gnosis-dashboard--output-themata (list id1)))
+ (vec (cadr (car entries))))
+ ;; Tags field (index 3) should contain both tags
+ (should (string-search "math" (aref vec 3)))
+ (should (string-search "algebra" (aref vec 3))))))
+
+(ert-deftest gnosis-test-dashboard-output-themata-strips-org-links ()
+ "Org-mode links in keimenon are simplified to description only."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema
+ deck-id "See [[id:abc-123][My Node]] for details" "A"))
+ (entries (gnosis-dashboard--output-themata (list id1)))
+ (vec (cadr (car entries))))
+ ;; Link syntax removed, description kept
+ (should (string-search "My Node" (aref vec 0)))
+ (should-not (string-search "[[id:" (aref vec 0))))))
+
+(ert-deftest gnosis-test-dashboard-output-themata-strips-newlines ()
+ "Newlines in fields are replaced with spaces."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Line1\nLine2" "A"))
+ (entries (gnosis-dashboard--output-themata (list id1)))
+ (vec (cadr (car entries))))
+ (should-not (string-search "\n" (aref vec 0)))
+ (should (string-search "Line1 Line2" (aref vec 0))))))
+
+(ert-deftest gnosis-test-dashboard-deck-thema-count ()
+ "Deck thema count returns correct count string."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck")))
+ ;; Empty deck
+ (should (equal (gnosis-dashboard-deck-thema-count deck-id) '("0")))
+ ;; Add two themata
+ (gnosis-test--add-basic-thema deck-id "Q1" "A1")
+ (gnosis-test--add-basic-thema deck-id "Q2" "A2")
+ (should (equal (gnosis-dashboard-deck-thema-count deck-id) '("2"))))))
+
+(ert-deftest gnosis-test-dashboard-deck-thema-count-nonexistent ()
+ "Deck thema count returns nil for nonexistent deck."
+ (gnosis-test-with-db
+ (should (null (gnosis-dashboard-deck-thema-count 99999)))))
+
+(ert-deftest gnosis-test-dashboard-output-tag ()
+ "Output-tag returns (tag count-string)."
+ (gnosis-test-with-db
+ (let ((deck-id (gnosis-test--add-deck "test-deck")))
+ (gnosis-test--add-basic-thema deck-id "Q1" "A1" '("math"))
+ (gnosis-test--add-basic-thema deck-id "Q2" "A2" '("math" "geo"))
+ (gnosis-test--add-basic-thema deck-id "Q3" "A3" '("geo"))
+ (let ((result (gnosis-dashboard-output-tag "math")))
+ (should (equal (car result) "math"))
+ (should (equal (cadr result) "2")))
+ (let ((result (gnosis-dashboard-output-tag "geo")))
+ (should (equal (cadr result) "2"))))))
+
+(ert-deftest gnosis-test-dashboard-output-deck ()
+ "Output-deck returns (name count) list."
+ (gnosis-test-with-db
+ (let ((deck-id (gnosis-test--add-deck "my-deck")))
+ (gnosis-test--add-basic-thema deck-id "Q1" "A1")
+ (gnosis-test--add-basic-thema deck-id "Q2" "A2")
+ (let ((result (gnosis-dashboard-output-deck deck-id)))
+ (should (equal (car result) "my-deck"))
+ (should (equal (cadr result) "2"))))))
+
+(ert-deftest gnosis-test-dashboard-get-themata-links ()
+ "Get-themata-links returns thema IDs linked to a node."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1"))
+ (id2 (gnosis-test--add-basic-thema deck-id "Q2" "A2"))
+ (_id3 (gnosis-test--add-basic-thema deck-id "Q3" "A3"))
+ (node-id "fake-node-uuid"))
+ ;; Link two themata to the node
+ (gnosis-test--add-link id1 node-id)
+ (gnosis-test--add-link id2 node-id)
+ (let ((result (gnosis-dashboard-get-themata-links node-id)))
+ (should (= (length result) 2))
+ ;; source column is TEXT but emacsql returns integers
+ (should (member id1 result))
+ (should (member id2 result))))))
+
+;; ──────────────────────────────────────────────────────────
+;; Entry manipulation tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-dashboard-remove-entries ()
+ "Remove-entries removes matching entries and updates thema-ids."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1"))
+ (id2 (gnosis-test--add-basic-thema deck-id "Q2" "A2"))
+ (id3 (gnosis-test--add-basic-thema deck-id "Q3" "A3")))
+ (gnosis-test-with-dashboard-buffer
+ (with-current-buffer gnosis-dashboard-buffer-name
+ (tabulated-list-mode)
+ (setq tabulated-list-format [("Col" 10 t)])
+ (tabulated-list-init-header)
+ (setq tabulated-list-entries
+ (list (list id1 ["Q1"]) (list id2 ["Q2"]) (list id3 ["Q3"])))
+ (setq gnosis-dashboard-thema-ids (list id1 id2 id3))
+ (tabulated-list-print t)
+ ;; Remove id2
+ (gnosis-dashboard--remove-entries (list id2))
+ ;; Two entries remain
+ (should (= (length tabulated-list-entries) 2))
+ (should-not (cl-find id2 tabulated-list-entries :key #'car))
+ ;; thema-ids also updated
+ (should-not (member id2 gnosis-dashboard-thema-ids)))))))
+
+(ert-deftest gnosis-test-dashboard-update-entries ()
+ "Update-entries refreshes data from DB for specified IDs."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Original Q" "A1")))
+ (gnosis-test-with-dashboard-buffer
+ (with-current-buffer gnosis-dashboard-buffer-name
+ (tabulated-list-mode)
+ (setq tabulated-list-format [("K" 10 t) ("H" 10 t) ("A" 10 t)
+ ("T" 10 t) ("Ty" 10 t) ("S" 5 t)])
+ (tabulated-list-init-header)
+ ;; Initial entries from DB
+ (let ((initial (gnosis-dashboard--output-themata (list id1))))
+ (setq tabulated-list-entries initial)
+ (tabulated-list-print t)
+ ;; Modify the thema in DB
+ (gnosis-update 'themata '(= keimenon "Updated Q") `(= id ,id1))
+ ;; Refresh
+ (gnosis-dashboard--update-entries (list id1))
+ ;; Entry should reflect the update
+ (let* ((entry (cl-find id1 tabulated-list-entries :key #'car))
+ (vec (cadr entry)))
+ (should (string-search "Updated Q" (aref vec 0))))))))))
+
+;; ──────────────────────────────────────────────────────────
+;; Buffer rendering tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-dashboard-render-themata ()
+ "Output-themata renders a buffer with themata-mode active."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1" '("math")))
+ (id2 (gnosis-test--add-basic-thema deck-id "Q2" "A2" '("geo"))))
+ (gnosis-test-with-dashboard-buffer
+ (gnosis-dashboard-output-themata (list id1 id2))
+ (with-current-buffer gnosis-dashboard-buffer-name
+ ;; Themata mode should be active
+ (should gnosis-dashboard-themata-mode)
+ ;; Other modes disabled
+ (should-not gnosis-dashboard-decks-mode)
+ (should-not gnosis-dashboard-tags-mode)
+ (should-not gnosis-dashboard-nodes-mode)
+ ;; Entries populated
+ (should (= (length tabulated-list-entries) 2))
+ ;; Current state tracked
+ (should (equal (plist-get gnosis-dashboard--current :type)
'themata)))))))
+
+(ert-deftest gnosis-test-dashboard-render-decks ()
+ "Output-decks renders a buffer with decks-mode active."
+ (gnosis-test-with-db
+ (let ((deck-id (gnosis-test--add-deck "deck-1")))
+ (gnosis-test--add-basic-thema deck-id "Q1" "A1")
+ (gnosis-test-with-dashboard-buffer
+ (gnosis-dashboard-output-decks)
+ (with-current-buffer gnosis-dashboard-buffer-name
+ ;; Decks mode active
+ (should gnosis-dashboard-decks-mode)
+ ;; Other modes disabled
+ (should-not gnosis-dashboard-themata-mode)
+ (should-not gnosis-dashboard-tags-mode)
+ (should-not gnosis-dashboard-nodes-mode)
+ ;; At least one entry
+ (should (>= (length tabulated-list-entries) 1)))))))
+
+(ert-deftest gnosis-test-dashboard-render-tags ()
+ "Output-tags renders a buffer with tags-mode active."
+ (gnosis-test-with-db
+ (let ((deck-id (gnosis-test--add-deck "test-deck")))
+ (gnosis-test--add-basic-thema deck-id "Q1" "A1" '("math"))
+ (gnosis-test--add-basic-thema deck-id "Q2" "A2" '("geo"))
+ (gnosis-test--setup-tags)
+ (gnosis-test-with-dashboard-buffer
+ (gnosis-dashboard-output-tags)
+ (with-current-buffer gnosis-dashboard-buffer-name
+ ;; Tags mode active
+ (should gnosis-dashboard-tags-mode)
+ ;; Other modes disabled
+ (should-not gnosis-dashboard-themata-mode)
+ (should-not gnosis-dashboard-decks-mode)
+ (should-not gnosis-dashboard-nodes-mode)
+ ;; Two unique tags
+ (should (= (length tabulated-list-entries) 2)))))))
+
+(ert-deftest gnosis-test-dashboard-mode-switching ()
+ "Switching between views disables the previous mode."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1" '("math"))))
+ (gnosis-test--setup-tags)
+ (gnosis-test-with-dashboard-buffer
+ ;; Start in themata mode
+ (gnosis-dashboard-output-themata (list id1))
+ (with-current-buffer gnosis-dashboard-buffer-name
+ (should gnosis-dashboard-themata-mode)
+ (should-not gnosis-dashboard-decks-mode))
+ ;; Switch to decks
+ (gnosis-dashboard-output-decks)
+ (with-current-buffer gnosis-dashboard-buffer-name
+ (should gnosis-dashboard-decks-mode)
+ (should-not gnosis-dashboard-themata-mode))
+ ;; Switch to tags
+ (gnosis-dashboard-output-tags)
+ (with-current-buffer gnosis-dashboard-buffer-name
+ (should gnosis-dashboard-tags-mode)
+ (should-not gnosis-dashboard-decks-mode)
+ (should-not gnosis-dashboard-themata-mode))
+ ;; Switch back to themata
+ (gnosis-dashboard-output-themata (list id1))
+ (with-current-buffer gnosis-dashboard-buffer-name
+ (should gnosis-dashboard-themata-mode)
+ (should-not gnosis-dashboard-tags-mode)
+ (should-not gnosis-dashboard-decks-mode))))))
+
+(ert-deftest gnosis-test-dashboard-render-themata-stores-ids ()
+ "Output-themata stores current IDs for history navigation."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1"))
+ (id2 (gnosis-test--add-basic-thema deck-id "Q2" "A2"))
+ (ids (list id1 id2)))
+ (gnosis-test-with-dashboard-buffer
+ (gnosis-dashboard-output-themata ids)
+ (with-current-buffer gnosis-dashboard-buffer-name
+ (should (equal gnosis-dashboard-themata-current-ids ids))
+ (should (equal gnosis-dashboard-thema-ids ids)))))))
+
+;; ──────────────────────────────────────────────────────────
+;; Mark/selection tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-dashboard-mark-all-and-unmark ()
+ "Mark-all collects all IDs, unmark-all clears them."
+ (gnosis-test-with-db
+ (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+ (id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1"))
+ (id2 (gnosis-test--add-basic-thema deck-id "Q2" "A2")))
+ (gnosis-test-with-dashboard-buffer
+ (gnosis-dashboard-output-themata (list id1 id2))
+ (with-current-buffer gnosis-dashboard-buffer-name
+ ;; Mark all
+ (gnosis-dashboard-mark-all)
+ (should (= (length gnosis-dashboard--selected-ids) 2))
+ ;; Unmark all
+ (gnosis-dashboard-unmark-all)
+ (should (null gnosis-dashboard--selected-ids)))))))
+
+(ert-run-tests-batch-and-exit)