Files
org-roam-project/org-roam-project-test.el

454 lines
21 KiB
EmacsLisp

;;; 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)))))))
(ert-deftest orp-test-context-via-directory-override ()
"Context is detected via `project-current-directory-override' (Emacs 29+).
Simulates the environment created by `project-switch-project': the
override is set to the target project while `default-directory' still
points elsewhere (e.g. the previously active project)."
(skip-unless (boundp 'project-current-directory-override))
(orp-test--with-initialized-project
(let ((project-current-directory-override test-root)
(default-directory "/")) ; deliberately wrong — not the project
(let ((ctx (org-roam-project--context)))
(should (consp ctx))
(should (string= (file-name-as-directory (car ctx))
(file-name-as-directory test-notes-dir)))))))
;;; ─── 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))))))
(ert-deftest orp-test-require-context-via-directory-override ()
"require-context succeeds via `project-current-directory-override' (Emacs 29+).
Simulates `project-switch-project' setting the override while
`default-directory' still points to the previous project."
(skip-unless (boundp 'project-current-directory-override))
(orp-test--with-initialized-project
(let ((project-current-directory-override test-root)
(default-directory "/")) ; deliberately wrong — not the 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)))))))
;;; ─── 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