initial commit
This commit is contained in:
426
org-roam-project-test.el
Normal file
426
org-roam-project-test.el
Normal file
@@ -0,0 +1,426 @@
|
||||
;;; org-roam-project-test.el --- ERT tests for org-roam-project -*- lexical-binding: t; -*-
|
||||
|
||||
;; Copyright (C) 2026
|
||||
|
||||
;; This file is NOT part of GNU Emacs.
|
||||
|
||||
;; This program is free software; you can redistribute it and/or modify
|
||||
;; it under the terms of the GNU General Public License as published by
|
||||
;; the Free Software Foundation, either version 3 of the License, or
|
||||
;; (at your option) any later version.
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; ERT test suite for org-roam-project.el.
|
||||
;;
|
||||
;; Tests use real org-roam (not mocked) to catch compatibility issues.
|
||||
;; Each test that needs a project creates a temporary git-initialized
|
||||
;; directory so that project.el's VC backend recognises it.
|
||||
;;
|
||||
;; Run from the command line:
|
||||
;;
|
||||
;; make test
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
(require 'org-roam)
|
||||
(require 'org-roam-project)
|
||||
|
||||
;;; ─── Test helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
(defun orp-test--make-temp-project ()
|
||||
"Create a temporary directory and initialise it as a git repository.
|
||||
Returns the absolute path to the project root (with trailing slash)."
|
||||
(let ((dir (file-name-as-directory
|
||||
(make-temp-file "orp-test-project-" t))))
|
||||
;; Git init so project.el recognises it via `project-try-vc'.
|
||||
(let ((default-directory dir))
|
||||
(call-process "git" nil nil nil "init" "--quiet")
|
||||
(call-process "git" nil nil nil "config" "user.email" "test@test.com")
|
||||
(call-process "git" nil nil nil "config" "user.name" "Test"))
|
||||
dir))
|
||||
|
||||
(defun orp-test--cleanup (dir)
|
||||
"Recursively delete DIR and remove it from `project--list' if present."
|
||||
(when (file-directory-p dir)
|
||||
(delete-directory dir t))
|
||||
;; Forget the project so it doesn't pollute other tests.
|
||||
;; Guard against Emacs 30's `unset' sentinel value for project--list.
|
||||
(when (and (boundp 'project--list)
|
||||
(listp project--list))
|
||||
(setq project--list
|
||||
(cl-remove-if (lambda (entry)
|
||||
(string-prefix-p dir (car entry)))
|
||||
project--list))))
|
||||
|
||||
(defmacro orp-test--with-project (&rest body)
|
||||
"Execute BODY inside a fresh temporary git project.
|
||||
Binds `test-root' (project root with trailing slash) and
|
||||
`test-notes-dir' (notes subdirectory path, not yet created).
|
||||
Cleans up unconditionally after BODY."
|
||||
(declare (indent 0) (debug t))
|
||||
`(let* ((test-root (orp-test--make-temp-project))
|
||||
(test-notes-dir (expand-file-name org-roam-project-notes-subdir test-root))
|
||||
(default-directory test-root))
|
||||
(unwind-protect
|
||||
(progn ,@body)
|
||||
(orp-test--cleanup test-root))))
|
||||
|
||||
(defmacro orp-test--with-initialized-project (&rest body)
|
||||
"Execute BODY inside a fresh project whose notes directory already exists.
|
||||
Also isolates `org-roam-directory' and `org-roam-db-location' so that no
|
||||
real org-roam database is opened globally."
|
||||
(declare (indent 0) (debug t))
|
||||
`(orp-test--with-project
|
||||
(make-directory test-notes-dir t)
|
||||
(let* ((org-roam-directory test-notes-dir)
|
||||
(org-roam-db-location
|
||||
(expand-file-name org-roam-project-db-filename test-notes-dir)))
|
||||
,@body)))
|
||||
|
||||
;;; ─── 1. Customisation defaults ───────────────────────────────────────────────
|
||||
|
||||
(ert-deftest orp-test-default-notes-subdir ()
|
||||
"Default value of `org-roam-project-notes-subdir' is \"notes\"."
|
||||
(should (equal (default-value 'org-roam-project-notes-subdir) "notes")))
|
||||
|
||||
(ert-deftest orp-test-default-db-filename ()
|
||||
"Default value of `org-roam-project-db-filename' is \".org-roam.db\"."
|
||||
(should (equal (default-value 'org-roam-project-db-filename) ".org-roam.db")))
|
||||
|
||||
(ert-deftest orp-test-default-auto-create ()
|
||||
"Default value of `org-roam-project-auto-create' is nil."
|
||||
(should (null (default-value 'org-roam-project-auto-create))))
|
||||
|
||||
;;; ─── 2. Safe-local-variable predicates ──────────────────────────────────────
|
||||
|
||||
(ert-deftest orp-test-safe-local-notes-subdir ()
|
||||
"`org-roam-project-notes-subdir' is safe as a dir-local when it is a string."
|
||||
(should (equal (get 'org-roam-project-notes-subdir 'safe-local-variable)
|
||||
#'stringp)))
|
||||
|
||||
(ert-deftest orp-test-safe-local-db-filename ()
|
||||
"`org-roam-project-db-filename' is safe as a dir-local when it is a string."
|
||||
(should (equal (get 'org-roam-project-db-filename 'safe-local-variable)
|
||||
#'stringp)))
|
||||
|
||||
;;; ─── 3. org-roam-project--value-in-dir ──────────────────────────────────────
|
||||
|
||||
(ert-deftest orp-test-value-in-dir-fallback ()
|
||||
"Falls back to the global value when no dir-locals override is present."
|
||||
(orp-test--with-project
|
||||
;; No .dir-locals.el exists, so the global default should be returned.
|
||||
(let ((org-roam-project-notes-subdir "notes"))
|
||||
(should (equal (org-roam-project--value-in-dir
|
||||
'org-roam-project-notes-subdir test-root)
|
||||
"notes")))))
|
||||
|
||||
(ert-deftest orp-test-value-in-dir-override ()
|
||||
"Returns the dir-local value when .dir-locals.el overrides the variable."
|
||||
(orp-test--with-project
|
||||
;; Write a .dir-locals.el that sets the notes subdir to "docs/notes".
|
||||
(let ((dir-locals-file (expand-file-name ".dir-locals.el" test-root)))
|
||||
(with-temp-file dir-locals-file
|
||||
(insert "((nil . ((org-roam-project-notes-subdir . \"docs/notes\"))))"))
|
||||
(should (equal (org-roam-project--value-in-dir
|
||||
'org-roam-project-notes-subdir test-root)
|
||||
"docs/notes")))))
|
||||
|
||||
;;; ─── 4. org-roam-project--context ───────────────────────────────────────────
|
||||
|
||||
(ert-deftest orp-test-context-outside-project ()
|
||||
"`org-roam-project--context' returns nil when not inside a project."
|
||||
;; Use the system temp dir which is unlikely to be a project root.
|
||||
(let ((default-directory temporary-file-directory))
|
||||
(should (null (org-roam-project--context temporary-file-directory)))))
|
||||
|
||||
(ert-deftest orp-test-context-no-notes-dir-auto-create-nil ()
|
||||
"Returns nil when the notes dir does not exist and auto-create is nil."
|
||||
(orp-test--with-project
|
||||
(let ((org-roam-project-auto-create nil))
|
||||
;; Notes dir has NOT been created.
|
||||
(should (null (org-roam-project--context test-root))))))
|
||||
|
||||
(ert-deftest orp-test-context-returns-cons-when-notes-dir-exists ()
|
||||
"Returns (notes-dir . db-path) when the notes dir exists."
|
||||
(orp-test--with-project
|
||||
(make-directory test-notes-dir t)
|
||||
(let ((ctx (org-roam-project--context test-root)))
|
||||
(should (consp ctx))
|
||||
;; Normalise both sides: the source uses expand-file-name without
|
||||
;; a trailing slash; file-name-as-directory makes comparison robust.
|
||||
(should (string= (file-name-as-directory (car ctx))
|
||||
(file-name-as-directory test-notes-dir)))
|
||||
(should (string= (cdr ctx)
|
||||
(expand-file-name org-roam-project-db-filename
|
||||
test-notes-dir))))))
|
||||
|
||||
(ert-deftest orp-test-context-auto-create ()
|
||||
"Auto-creates the notes dir when `org-roam-project-auto-create' is non-nil."
|
||||
(orp-test--with-project
|
||||
(let ((org-roam-project-auto-create t))
|
||||
;; Notes dir does not exist yet.
|
||||
(should (not (file-directory-p test-notes-dir)))
|
||||
(let ((ctx (org-roam-project--context test-root)))
|
||||
(should (consp ctx))
|
||||
;; The directory should now have been created.
|
||||
(should (file-directory-p test-notes-dir))))))
|
||||
|
||||
(ert-deftest orp-test-context-respects-custom-subdir ()
|
||||
"Context uses a custom notes subdir."
|
||||
(orp-test--with-project
|
||||
(let* ((org-roam-project-notes-subdir "custom-notes")
|
||||
(custom-notes-dir (expand-file-name "custom-notes" test-root)))
|
||||
(make-directory custom-notes-dir t)
|
||||
(let ((ctx (org-roam-project--context test-root)))
|
||||
(should (consp ctx))
|
||||
(should (string-prefix-p custom-notes-dir (car ctx)))))))
|
||||
|
||||
(ert-deftest orp-test-context-respects-custom-db-filename ()
|
||||
"Context uses a custom database filename."
|
||||
(orp-test--with-project
|
||||
(let ((org-roam-project-db-filename "my-custom.db"))
|
||||
(make-directory test-notes-dir t)
|
||||
(let ((ctx (org-roam-project--context test-root)))
|
||||
(should (consp ctx))
|
||||
(should (string-suffix-p "my-custom.db" (cdr ctx)))))))
|
||||
|
||||
;;; ─── 5. org-roam-project--with-context (macro) ──────────────────────────────
|
||||
|
||||
(ert-deftest orp-test-with-context-binds-org-roam-vars ()
|
||||
"The macro rebinds `org-roam-directory' and `org-roam-db-location'."
|
||||
(orp-test--with-initialized-project
|
||||
;; Inside an initialized project the macro should bind to project values.
|
||||
(let ((captured-dir nil)
|
||||
(captured-db nil))
|
||||
(org-roam-project--with-context
|
||||
(setq captured-dir org-roam-directory)
|
||||
(setq captured-db org-roam-db-location))
|
||||
(should (string= (file-name-as-directory captured-dir)
|
||||
(file-name-as-directory test-notes-dir)))
|
||||
(should (string= captured-db
|
||||
(expand-file-name org-roam-project-db-filename
|
||||
test-notes-dir))))))
|
||||
|
||||
(ert-deftest orp-test-with-context-passthrough-outside-project ()
|
||||
"The macro uses global values when there is no project context."
|
||||
(let* ((global-dir "/tmp/global-notes/")
|
||||
(global-db "/tmp/global-notes/.org-roam.db")
|
||||
(org-roam-directory global-dir)
|
||||
(org-roam-db-location global-db)
|
||||
(default-directory temporary-file-directory)
|
||||
(captured-dir nil)
|
||||
(captured-db nil))
|
||||
(org-roam-project--with-context
|
||||
(setq captured-dir org-roam-directory)
|
||||
(setq captured-db org-roam-db-location))
|
||||
(should (string= captured-dir global-dir))
|
||||
(should (string= captured-db global-db))))
|
||||
|
||||
;;; ─── 6. org-roam-project--require-context ───────────────────────────────────
|
||||
|
||||
(ert-deftest orp-test-require-context-no-project ()
|
||||
"Signals `user-error' when not inside a project."
|
||||
(let ((default-directory temporary-file-directory))
|
||||
(should-error (org-roam-project--require-context)
|
||||
:type 'user-error)))
|
||||
|
||||
(ert-deftest orp-test-require-context-no-notes-dir ()
|
||||
"Signals `user-error' mentioning `org-roam-project-init' when notes dir absent."
|
||||
(orp-test--with-project
|
||||
(let ((err (should-error (org-roam-project--require-context)
|
||||
:type 'user-error)))
|
||||
(should (string-match-p "org-roam-project-init"
|
||||
(cadr err))))))
|
||||
|
||||
(ert-deftest orp-test-require-context-success ()
|
||||
"Returns (notes-dir . db-path) when the project is properly initialised."
|
||||
(orp-test--with-initialized-project
|
||||
(let ((ctx (org-roam-project--require-context)))
|
||||
(should (consp ctx))
|
||||
(should (string= (file-name-as-directory (car ctx))
|
||||
(file-name-as-directory test-notes-dir)))
|
||||
(should (string= (cdr ctx)
|
||||
(expand-file-name org-roam-project-db-filename
|
||||
test-notes-dir))))))
|
||||
|
||||
;;; ─── 7. org-roam-project--add-to-gitignore ──────────────────────────────────
|
||||
|
||||
(ert-deftest orp-test-gitignore-no-file ()
|
||||
"Does nothing when there is no .gitignore in the project root."
|
||||
(orp-test--with-project
|
||||
;; Ensure no .gitignore exists.
|
||||
(let ((gitignore (expand-file-name ".gitignore" test-root)))
|
||||
(should (not (file-exists-p gitignore)))
|
||||
(org-roam-project--add-to-gitignore test-root ".org-roam.db")
|
||||
;; Still should not exist.
|
||||
(should (not (file-exists-p gitignore))))))
|
||||
|
||||
(ert-deftest orp-test-gitignore-appends-entry ()
|
||||
"Appends the db filename as a new line when not already present."
|
||||
(orp-test--with-project
|
||||
(let ((gitignore (expand-file-name ".gitignore" test-root)))
|
||||
(with-temp-file gitignore
|
||||
(insert "*.log\n"))
|
||||
(org-roam-project--add-to-gitignore test-root ".org-roam.db")
|
||||
(let ((contents (with-temp-buffer
|
||||
(insert-file-contents gitignore)
|
||||
(buffer-string))))
|
||||
(should (string-match-p "^\.org-roam\.db$" contents))))))
|
||||
|
||||
(ert-deftest orp-test-gitignore-no-duplicate ()
|
||||
"Does not append the entry when it is already present."
|
||||
(orp-test--with-project
|
||||
(let ((gitignore (expand-file-name ".gitignore" test-root)))
|
||||
(with-temp-file gitignore
|
||||
(insert "*.log\n.org-roam.db\n"))
|
||||
(org-roam-project--add-to-gitignore test-root ".org-roam.db")
|
||||
(let ((contents (with-temp-buffer
|
||||
(insert-file-contents gitignore)
|
||||
(buffer-string))))
|
||||
;; Count occurrences — should be exactly 1.
|
||||
(let ((count 0)
|
||||
(start 0))
|
||||
(while (string-match "\\.org-roam\\.db" contents start)
|
||||
(cl-incf count)
|
||||
(setq start (match-end 0)))
|
||||
(should (= count 1)))))))
|
||||
|
||||
(ert-deftest orp-test-gitignore-adds-newline-before-entry ()
|
||||
"Ensures a newline is inserted before the entry when the file lacks one."
|
||||
(orp-test--with-project
|
||||
(let ((gitignore (expand-file-name ".gitignore" test-root)))
|
||||
;; Write file WITHOUT a trailing newline.
|
||||
(with-temp-file gitignore
|
||||
(insert "*.log"))
|
||||
(org-roam-project--add-to-gitignore test-root ".org-roam.db")
|
||||
(let ((contents (with-temp-buffer
|
||||
(insert-file-contents gitignore)
|
||||
(buffer-string))))
|
||||
;; The db entry should be on its own line (preceded by \n).
|
||||
(should (string-match-p "\n\\.org-roam\\.db\n" contents))))))
|
||||
|
||||
;;; ─── 8. org-roam-project--update-file-advice ────────────────────────────────
|
||||
|
||||
(ert-deftest orp-test-update-file-advice-with-project ()
|
||||
"Advice rebinds org-roam vars when the file belongs to an initialised project."
|
||||
(orp-test--with-initialized-project
|
||||
(let* ((note-file (expand-file-name "test-note.org" test-notes-dir))
|
||||
(seen-dir nil)
|
||||
(seen-db nil)
|
||||
(orig-fn (lambda (&optional _file _no-req)
|
||||
(setq seen-dir org-roam-directory)
|
||||
(setq seen-db org-roam-db-location))))
|
||||
(with-temp-file note-file (insert "* Test\n"))
|
||||
(org-roam-project--update-file-advice orig-fn note-file nil)
|
||||
(should (string= (file-name-as-directory seen-dir)
|
||||
(file-name-as-directory test-notes-dir)))
|
||||
(should (string= seen-db
|
||||
(expand-file-name org-roam-project-db-filename
|
||||
test-notes-dir))))))
|
||||
|
||||
(ert-deftest orp-test-update-file-advice-without-project ()
|
||||
"Advice calls orig-fn unchanged when the file is not in any project."
|
||||
(let* ((global-dir "/tmp/global-roam/")
|
||||
(global-db "/tmp/global-roam/.org-roam.db")
|
||||
(org-roam-directory global-dir)
|
||||
(org-roam-db-location global-db)
|
||||
(seen-dir nil)
|
||||
(seen-db nil)
|
||||
(orig-fn (lambda (&optional _file _no-req)
|
||||
(setq seen-dir org-roam-directory)
|
||||
(setq seen-db org-roam-db-location)))
|
||||
;; A file that lives in /tmp and is not in any VC project.
|
||||
(lone-file (concat temporary-file-directory "lone-file.org")))
|
||||
(org-roam-project--update-file-advice orig-fn lone-file nil)
|
||||
(should (string= seen-dir global-dir))
|
||||
(should (string= seen-db global-db))))
|
||||
|
||||
;;; ─── 9. project-switch-commands integration ─────────────────────────────────
|
||||
|
||||
(defun orp-test--switch-command-present-p (cmd)
|
||||
"Return non-nil when CMD appears in `project-switch-commands'."
|
||||
(cl-find cmd project-switch-commands :key #'car))
|
||||
|
||||
(ert-deftest orp-test-register-adds-commands ()
|
||||
"Registering adds both roam-find and capture entries."
|
||||
(let ((project-switch-commands (copy-sequence project-switch-commands)))
|
||||
(org-roam-project--register-switch-commands)
|
||||
(should (orp-test--switch-command-present-p 'org-roam-project-node-find))
|
||||
(should (orp-test--switch-command-present-p 'org-roam-project-capture))))
|
||||
|
||||
(ert-deftest orp-test-unregister-removes-commands ()
|
||||
"Unregistering removes both entries."
|
||||
(let ((project-switch-commands (copy-sequence project-switch-commands)))
|
||||
(org-roam-project--register-switch-commands)
|
||||
(org-roam-project--unregister-switch-commands)
|
||||
(should (not (orp-test--switch-command-present-p 'org-roam-project-node-find)))
|
||||
(should (not (orp-test--switch-command-present-p 'org-roam-project-capture)))))
|
||||
|
||||
(ert-deftest orp-test-register-is-idempotent ()
|
||||
"Calling register twice does not produce duplicate entries."
|
||||
(let ((project-switch-commands (copy-sequence project-switch-commands)))
|
||||
(org-roam-project--register-switch-commands)
|
||||
(org-roam-project--register-switch-commands)
|
||||
(let ((find-count
|
||||
(cl-count 'org-roam-project-node-find project-switch-commands
|
||||
:key #'car))
|
||||
(capture-count
|
||||
(cl-count 'org-roam-project-capture project-switch-commands
|
||||
:key #'car)))
|
||||
(should (= find-count 1))
|
||||
(should (= capture-count 1)))))
|
||||
|
||||
;;; ─── 10. org-roam-project-mode ──────────────────────────────────────────────
|
||||
|
||||
(defmacro orp-test--with-mode-off (&rest body)
|
||||
"Execute BODY ensuring `org-roam-project-mode' is disabled beforehand
|
||||
and restored to its prior state afterward."
|
||||
(declare (indent 0) (debug t))
|
||||
`(let ((was-on org-roam-project-mode)
|
||||
(project-switch-commands (copy-sequence project-switch-commands)))
|
||||
(when org-roam-project-mode
|
||||
(org-roam-project-mode -1))
|
||||
(unwind-protect
|
||||
(progn ,@body)
|
||||
;; Restore: ensure mode is off after the test regardless.
|
||||
(when org-roam-project-mode
|
||||
(org-roam-project-mode -1))
|
||||
(when was-on
|
||||
(org-roam-project-mode 1)))))
|
||||
|
||||
(ert-deftest orp-test-mode-enable-installs-advice ()
|
||||
"Enabling the mode installs advice on `org-roam-db-update-file'."
|
||||
(orp-test--with-mode-off
|
||||
(org-roam-project-mode 1)
|
||||
(should (advice-member-p #'org-roam-project--update-file-advice
|
||||
'org-roam-db-update-file))))
|
||||
|
||||
(ert-deftest orp-test-mode-disable-removes-advice ()
|
||||
"Disabling the mode removes the advice."
|
||||
(orp-test--with-mode-off
|
||||
(org-roam-project-mode 1)
|
||||
(org-roam-project-mode -1)
|
||||
(should (not (advice-member-p #'org-roam-project--update-file-advice
|
||||
'org-roam-db-update-file)))))
|
||||
|
||||
(ert-deftest orp-test-mode-enable-registers-switch-commands ()
|
||||
"Enabling the mode adds commands to `project-switch-commands'."
|
||||
(orp-test--with-mode-off
|
||||
(org-roam-project-mode 1)
|
||||
(should (orp-test--switch-command-present-p 'org-roam-project-node-find))
|
||||
(should (orp-test--switch-command-present-p 'org-roam-project-capture))))
|
||||
|
||||
(ert-deftest orp-test-mode-disable-unregisters-switch-commands ()
|
||||
"Disabling the mode removes commands from `project-switch-commands'."
|
||||
(orp-test--with-mode-off
|
||||
(org-roam-project-mode 1)
|
||||
(org-roam-project-mode -1)
|
||||
(should (not (orp-test--switch-command-present-p 'org-roam-project-node-find)))
|
||||
(should (not (orp-test--switch-command-present-p 'org-roam-project-capture)))))
|
||||
|
||||
(provide 'org-roam-project-test)
|
||||
|
||||
;;; org-roam-project-test.el ends here
|
||||
Reference in New Issue
Block a user