;;; 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