diff options
| author | Craig Jennings <c@cjennings.net> | 2025-10-26 22:42:56 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-10-26 22:42:56 -0500 |
| commit | 671539db07570ceba1e078c944e49bbbe8d69702 (patch) | |
| tree | 94f2a3b5792f40ab957ccf452ccc12af1e7b1f60 | |
| parent | d76d1aac7a77434c4a7ecdf5af855572c223b22e (diff) | |
feat:org-roam-config: Add new utilities and tests
Introduce several utility functions for org-roam configuration,
including `cj/--generate-roam-slug`, `cj/--demote-org-subtree`, and
`cj/--format-roam-node`. These enhance title-to-slug conversion,
subtree demotion, and node file formatting.
Additionally, include comprehensive test cases for these functions
to ensure correctness and reliability in various scenarios.
| -rw-r--r-- | modules/org-roam-config.el | 88 | ||||
| -rw-r--r-- | tests/test-org-roam-config-demote.el | 183 | ||||
| -rw-r--r-- | tests/test-org-roam-config-format.el | 151 | ||||
| -rw-r--r-- | tests/test-org-roam-config-link-description.el | 188 | ||||
| -rw-r--r-- | tests/test-org-roam-config-slug.el | 223 |
5 files changed, 802 insertions, 31 deletions
diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el index 18552b1d..07098743 100644 --- a/modules/org-roam-config.el +++ b/modules/org-roam-config.el @@ -1,7 +1,15 @@ ;;; org-roam-config.el --- Org-Roam Config -*- lexical-binding: t; coding: utf-8; -*- ;; author: Craig Jennings <c@cjennings.net> ;;; Commentary: -;; Currently a work in progress. The initial version of this was taken from David Wilson: +;; Configuration and utilities for org-roam knowledge management. +;; +;; Key features: +;; - Custom capture templates for different node types (v2mom, recipe, topic) +;; - Automatic moving of completed tasks to daily journal +;; - Tag-based node filtering and finding +;; - Branch extraction to new roam nodes (cj/move-org-branch-to-roam) +;; +;; The initial version was adapted from David Wilson: ;; https://systemcrafters.net/build-a-second-brain-in-emacs/5-org-roam-hacks/ ;;; Code: @@ -190,6 +198,51 @@ Otherwise return TEXT unchanged." (or description url)) text)) +(defun cj/--generate-roam-slug (title) + "Convert TITLE to a filename-safe slug. +Converts to lowercase, replaces non-alphanumeric characters with hyphens, +and removes leading/trailing hyphens. +Returns the slugified string." + (let ((slug (replace-regexp-in-string + "[^a-zA-Z0-9]+" "-" + (downcase title)))) + (replace-regexp-in-string "^-\\|-$" "" slug))) + +(defun cj/--demote-org-subtree (content from-level to-level) + "Demote org subtree CONTENT from FROM-LEVEL to TO-LEVEL. +CONTENT is the org-mode text with headings. +FROM-LEVEL is the current level of the top heading (integer). +TO-LEVEL is the desired level for the top heading (integer). +Returns the demoted content as a string. +All headings in the tree are adjusted proportionally." + (if (<= from-level to-level) + ;; No demotion needed + content + (let ((demote-count (- from-level to-level))) + (with-temp-buffer + (insert content) + (goto-char (point-min)) + (while (re-search-forward "^\\(\\*+\\) " nil t) + (let* ((stars (match-string 1)) + (level (length stars)) + (new-level (max 1 (- level demote-count))) + (new-stars (make-string new-level ?*))) + (replace-match (concat new-stars " ")))) + (buffer-string))))) + +(defun cj/--format-roam-node (title node-id content) + "Format org-roam node file CONTENT with TITLE and NODE-ID. +TITLE is the node title string. +NODE-ID is the unique identifier for the node. +CONTENT is the main body content (already demoted if needed). +Returns the complete file content as a string." + (concat ":PROPERTIES:\n" + ":ID: " node-id "\n" + ":END:\n" + "#+TITLE: " title "\n" + "#+CATEGORY: " title "\n" + "#+FILETAGS: Topic\n\n" + content)) (defun cj/move-org-branch-to-roam () "Move the org subtree at point to a new org-roam node. @@ -213,12 +266,7 @@ title." (title (cj/org-link-get-description raw-title)) (timestamp (format-time-string "%Y%m%d%H%M%S")) ;; Convert title to filename-safe format - (title-slug (replace-regexp-in-string - "[^a-zA-Z0-9]+" "-" - (downcase title))) - ;; Remove leading/trailing hyphens - (title-slug (replace-regexp-in-string - "^-\\|-$" "" title-slug)) + (title-slug (cj/--generate-roam-slug title)) (filename (format "%s-%s.org" timestamp title-slug)) (filepath (expand-file-name filename org-roam-directory)) ;; Generate a unique ID for the node @@ -234,33 +282,11 @@ title." (org-cut-subtree) ;; Process the subtree to demote it to level 1 - (with-temp-buffer - (org-mode) - (insert subtree-content) - ;; Demote the entire tree so the top level becomes level 1 - (goto-char (point-min)) - (when (> current-level 1) - (let ((demote-count (- current-level 1))) - (while (re-search-forward "^\\*+ " nil t) - (beginning-of-line) - (dotimes (_ demote-count) - (when (looking-at "^\\*\\*") - (delete-char 1))) - (forward-line)))) - (setq subtree-content (buffer-string))) + (setq subtree-content (cj/--demote-org-subtree subtree-content current-level 1)) ;; Create the new org-roam file (with-temp-file filepath - ;; Insert the org-roam template with ID at file level - (insert ":PROPERTIES:\n") - (insert ":ID: " node-id "\n") - (insert ":END:\n") - (insert "#+TITLE: " title "\n") - (insert "#+CATEGORY: " title "\n") - (insert "#+FILETAGS: Topic\n\n") - - ;; Insert the demoted subtree content - (insert subtree-content)) + (insert (cj/--format-roam-node title node-id subtree-content))) ;; Sync the org-roam database (org-roam-db-sync) diff --git a/tests/test-org-roam-config-demote.el b/tests/test-org-roam-config-demote.el new file mode 100644 index 00000000..98cc8244 --- /dev/null +++ b/tests/test-org-roam-config-demote.el @@ -0,0 +1,183 @@ +;;; test-org-roam-config-demote.el --- Tests for cj/--demote-org-subtree -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--demote-org-subtree function from org-roam-config.el +;; +;; This function demotes org subtree content from one level to another. +;; All headings in the tree are adjusted proportionally, with a minimum level of 1. +;; +;; Examples: +;; Input: "*** Heading\n**** Sub", from: 3, to: 1 +;; Output: "* Heading\n** Sub" +;; +;; Input: "** Heading\n*** Sub", from: 2, to: 1 +;; Output: "* Heading\n** Sub" + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Now load the actual production module +(require 'org-roam-config) + +;;; Test Helpers + +(defun test-demote (content from-level to-level) + "Test cj/--demote-org-subtree on CONTENT. +FROM-LEVEL is the current top level, TO-LEVEL is the desired top level. +Returns the demoted content." + (cj/--demote-org-subtree content from-level to-level)) + +;;; Normal Cases - Single Heading + +(ert-deftest test-demote-level2-to-level1 () + "Should demote level 2 heading to level 1." + (let ((result (test-demote "** Heading\n" 2 1))) + (should (string= result "* Heading\n")))) + +(ert-deftest test-demote-level3-to-level1 () + "Should demote level 3 heading to level 1." + (let ((result (test-demote "*** Heading\n" 3 1))) + (should (string= result "* Heading\n")))) + +(ert-deftest test-demote-level4-to-level1 () + "Should demote level 4 heading to level 1." + (let ((result (test-demote "**** Heading\n" 4 1))) + (should (string= result "* Heading\n")))) + +(ert-deftest test-demote-level3-to-level2 () + "Should demote level 3 heading to level 2." + (let ((result (test-demote "*** Heading\n" 3 2))) + (should (string= result "** Heading\n")))) + +;;; Normal Cases - Multiple Headings at Same Level + +(ert-deftest test-demote-multiple-same-level () + "Should demote multiple headings at same level." + (let ((result (test-demote "** First\n** Second\n** Third\n" 2 1))) + (should (string= result "* First\n* Second\n* Third\n")))) + +;;; Normal Cases - Hierarchical Structure + +(ert-deftest test-demote-with-subheading () + "Should demote heading and subheading proportionally." + (let ((result (test-demote "** Heading\n*** Subheading\n" 2 1))) + (should (string= result "* Heading\n** Subheading\n")))) + +(ert-deftest test-demote-three-levels () + "Should demote three-level hierarchy." + (let ((result (test-demote "** Main\n*** Sub\n**** SubSub\n" 2 1))) + (should (string= result "* Main\n** Sub\n*** SubSub\n")))) + +(ert-deftest test-demote-complex-hierarchy () + "Should demote complex hierarchy maintaining relative structure." + (let ((result (test-demote "*** Top\n**** Sub1\n***** Deep\n**** Sub2\n" 3 1))) + (should (string= result "* Top\n** Sub1\n*** Deep\n** Sub2\n")))) + +;;; Normal Cases - With Content + +(ert-deftest test-demote-heading-with-text () + "Should demote heading preserving body text." + (let ((result (test-demote "** Heading\nBody text\n" 2 1))) + (should (string= result "* Heading\nBody text\n")))) + +(ert-deftest test-demote-with-properties () + "Should demote heading preserving properties." + (let ((result (test-demote "** Heading\n:PROPERTIES:\n:ID: 123\n:END:\n" 2 1))) + (should (string= result "* Heading\n:PROPERTIES:\n:ID: 123\n:END:\n")))) + +(ert-deftest test-demote-with-mixed-content () + "Should demote headings preserving all content." + (let ((result (test-demote "** H1\nText\n*** H2\nMore text\n" 2 1))) + (should (string= result "* H1\nText\n** H2\nMore text\n")))) + +;;; Boundary Cases - No Demotion Needed + +(ert-deftest test-demote-same-level () + "Should return content unchanged when from equals to." + (let ((result (test-demote "* Heading\n" 1 1))) + (should (string= result "* Heading\n")))) + +(ert-deftest test-demote-promote-ignored () + "Should return content unchanged when to > from (promotion)." + (let ((result (test-demote "* Heading\n" 1 2))) + (should (string= result "* Heading\n")))) + +;;; Boundary Cases - Minimum Level + +(ert-deftest test-demote-respects-minimum-level () + "Should not demote below level 1." + (let ((result (test-demote "** Main\n*** Sub\n" 2 1))) + (should (string= result "* Main\n** Sub\n")) + ;; Sub went from 3 to 2, not below 1 + (should (string-match-p "^\\*\\* Sub" result)))) + +(ert-deftest test-demote-deep-hierarchy-min-level () + "Should respect minimum level for deep hierarchies." + (let ((result (test-demote "**** L4\n***** L5\n****** L6\n" 4 1))) + (should (string= result "* L4\n** L5\n*** L6\n")))) + +;;; Boundary Cases - Empty and Edge Cases + +(ert-deftest test-demote-empty-string () + "Should handle empty string." + (let ((result (test-demote "" 2 1))) + (should (string= result "")))) + +(ert-deftest test-demote-no-headings () + "Should return non-heading content unchanged." + (let ((result (test-demote "Just plain text\nNo headings here\n" 2 1))) + (should (string= result "Just plain text\nNo headings here\n")))) + +(ert-deftest test-demote-heading-without-space () + "Should not match headings without space after stars." + (let ((result (test-demote "**Not a heading\n** Real Heading\n" 2 1))) + (should (string= result "**Not a heading\n* Real Heading\n")))) + +;;; Edge Cases - Special Heading Content + +(ert-deftest test-demote-heading-with-tags () + "Should demote heading preserving tags." + (let ((result (test-demote "** Heading :tag1:tag2:\n" 2 1))) + (should (string= result "* Heading :tag1:tag2:\n")))) + +(ert-deftest test-demote-heading-with-todo () + "Should demote heading preserving TODO keyword." + (let ((result (test-demote "** TODO Task\n" 2 1))) + (should (string= result "* TODO Task\n")))) + +(ert-deftest test-demote-heading-with-priority () + "Should demote heading preserving priority." + (let ((result (test-demote "** [#A] Important\n" 2 1))) + (should (string= result "* [#A] Important\n")))) + +;;; Edge Cases - Whitespace + +(ert-deftest test-demote-preserves-indentation () + "Should preserve indentation in body text." + (let ((result (test-demote "** Heading\n Indented text\n" 2 1))) + (should (string= result "* Heading\n Indented text\n")))) + +(ert-deftest test-demote-multiple-spaces-after-stars () + "Should handle multiple spaces after stars." + (let ((result (test-demote "** Heading\n" 2 1))) + (should (string= result "* Heading\n")))) + +;;; Edge Cases - Large Demotion + +(ert-deftest test-demote-large-level-difference () + "Should handle large level differences." + (let ((result (test-demote "****** Level 6\n******* Level 7\n" 6 1))) + (should (string= result "* Level 6\n** Level 7\n")))) + +(ert-deftest test-demote-to-level-2 () + "Should demote to level 2 when specified." + (let ((result (test-demote "***** Level 5\n****** Level 6\n" 5 2))) + (should (string= result "** Level 5\n*** Level 6\n")))) + +(provide 'test-org-roam-config-demote) +;;; test-org-roam-config-demote.el ends here diff --git a/tests/test-org-roam-config-format.el b/tests/test-org-roam-config-format.el new file mode 100644 index 00000000..e9378b7a --- /dev/null +++ b/tests/test-org-roam-config-format.el @@ -0,0 +1,151 @@ +;;; test-org-roam-config-format.el --- Tests for cj/--format-roam-node -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--format-roam-node function from org-roam-config.el +;; +;; This function formats org-roam node file content with title, node-id, and body content. +;; It creates a complete org-roam file with properties, title, category, and filetags. +;; +;; Example: +;; Input: title: "My Note", node-id: "abc123", content: "* Content\n" +;; Output: Full org-roam file with metadata and content + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Now load the actual production module +(require 'org-roam-config) + +;;; Test Helpers + +(defun test-format (title node-id content) + "Test cj/--format-roam-node with TITLE, NODE-ID, and CONTENT. +Returns the formatted file content." + (cj/--format-roam-node title node-id content)) + +;;; Normal Cases + +(ert-deftest test-format-simple-node () + "Should format simple node with all components." + (let ((result (test-format "Test Title" "id-123" "* Content\n"))) + (should (string-match-p ":PROPERTIES:" result)) + (should (string-match-p ":ID: id-123" result)) + (should (string-match-p "#\\+TITLE: Test Title" result)) + (should (string-match-p "#\\+CATEGORY: Test Title" result)) + (should (string-match-p "#\\+FILETAGS: Topic" result)) + (should (string-match-p "\\* Content" result)))) + +(ert-deftest test-format-properties-first () + "Should place properties at the beginning." + (let ((result (test-format "Title" "id" "content"))) + (should (string-prefix-p ":PROPERTIES:\n" result)))) + +(ert-deftest test-format-id-after-properties () + "Should place ID in properties block." + (let ((result (test-format "Title" "test-id-456" "content"))) + (should (string-match-p ":PROPERTIES:\n:ID: test-id-456\n:END:" result)))) + +(ert-deftest test-format-title-after-properties () + "Should place title after properties." + (let ((result (test-format "My Title" "id" "content"))) + (should (string-match-p ":END:\n#\\+TITLE: My Title\n" result)))) + +(ert-deftest test-format-category-matches-title () + "Should set category to match title." + (let ((result (test-format "Project Name" "id" "content"))) + (should (string-match-p "#\\+TITLE: Project Name\n#\\+CATEGORY: Project Name\n" result)))) + +(ert-deftest test-format-filetags-topic () + "Should set filetags to Topic." + (let ((result (test-format "Title" "id" "content"))) + (should (string-match-p "#\\+FILETAGS: Topic\n" result)))) + +(ert-deftest test-format-content-at-end () + "Should place content after metadata." + (let ((result (test-format "Title" "id" "* Heading\nBody text\n"))) + (should (string-suffix-p "* Heading\nBody text\n" result)))) + +;;; Edge Cases - Various Titles + +(ert-deftest test-format-title-with-spaces () + "Should handle title with spaces." + (let ((result (test-format "Multi Word Title" "id" "content"))) + (should (string-match-p "#\\+TITLE: Multi Word Title" result)) + (should (string-match-p "#\\+CATEGORY: Multi Word Title" result)))) + +(ert-deftest test-format-title-with-punctuation () + "Should handle title with punctuation." + (let ((result (test-format "Title: With, Punctuation!" "id" "content"))) + (should (string-match-p "#\\+TITLE: Title: With, Punctuation!" result)))) + +(ert-deftest test-format-title-with-numbers () + "Should handle title with numbers." + (let ((result (test-format "Version 2.0" "id" "content"))) + (should (string-match-p "#\\+TITLE: Version 2\\.0" result)))) + +;;; Edge Cases - Various Node IDs + +(ert-deftest test-format-uuid-style-id () + "Should handle UUID-style ID." + (let ((result (test-format "Title" "a1b2c3d4-e5f6-7890-abcd-ef1234567890" "content"))) + (should (string-match-p ":ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890" result)))) + +(ert-deftest test-format-short-id () + "Should handle short ID." + (let ((result (test-format "Title" "1" "content"))) + (should (string-match-p ":ID: 1" result)))) + +(ert-deftest test-format-long-id () + "Should handle long ID." + (let* ((long-id (make-string 100 ?a)) + (result (test-format "Title" long-id "content"))) + (should (string-match-p (concat ":ID: " long-id) result)))) + +;;; Edge Cases - Various Content + +(ert-deftest test-format-empty-content () + "Should handle empty content." + (let ((result (test-format "Title" "id" ""))) + (should (string-suffix-p "#+FILETAGS: Topic\n\n" result)))) + +(ert-deftest test-format-multiline-content () + "Should handle multiline content." + (let ((result (test-format "Title" "id" "* H1\nText\n** H2\nMore\n"))) + (should (string-suffix-p "* H1\nText\n** H2\nMore\n" result)))) + +(ert-deftest test-format-content-with-properties () + "Should handle content that already has properties." + (let ((result (test-format "Title" "id" "* Heading\n:PROPERTIES:\n:CUSTOM: value\n:END:\n"))) + (should (string-match-p ":CUSTOM: value" result)))) + +;;; Integration Tests - Structure + +(ert-deftest test-format-complete-structure () + "Should create proper org-roam file structure." + (let ((result (test-format "My Note" "abc-123" "* Content\n"))) + ;; Check order of components + (should (< (string-match ":PROPERTIES:" result) + (string-match ":ID:" result))) + (should (< (string-match ":ID:" result) + (string-match ":END:" result))) + (should (< (string-match ":END:" result) + (string-match "#\\+TITLE:" result))) + (should (< (string-match "#\\+TITLE:" result) + (string-match "#\\+CATEGORY:" result))) + (should (< (string-match "#\\+CATEGORY:" result) + (string-match "#\\+FILETAGS:" result))) + (should (< (string-match "#\\+FILETAGS:" result) + (string-match "\\* Content" result))))) + +(ert-deftest test-format-double-newline-after-metadata () + "Should have double newline between metadata and content." + (let ((result (test-format "Title" "id" "* Content"))) + (should (string-match-p "#\\+FILETAGS: Topic\n\n\\* Content" result)))) + +(provide 'test-org-roam-config-format) +;;; test-org-roam-config-format.el ends here diff --git a/tests/test-org-roam-config-link-description.el b/tests/test-org-roam-config-link-description.el new file mode 100644 index 00000000..06321b8f --- /dev/null +++ b/tests/test-org-roam-config-link-description.el @@ -0,0 +1,188 @@ +;;; test-org-roam-config-link-description.el --- Tests for cj/org-link-get-description -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/org-link-get-description function from org-roam-config.el +;; +;; This function extracts the description from an org link, or returns the text unchanged. +;; If TEXT contains an org link like [[url][description]], it returns description. +;; If TEXT contains multiple links, only the first one is processed. +;; Otherwise it returns TEXT unchanged. +;; +;; Examples: +;; Input: "[[https://example.com][Example Site]]" +;; Output: "Example Site" +;; +;; Input: "[[https://example.com]]" +;; Output: "https://example.com" +;; +;; Input: "Plain text" +;; Output: "Plain text" + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Now load the actual production module +(require 'org-roam-config) + +;;; Test Helpers + +(defun test-link-description (text) + "Test cj/org-link-get-description on TEXT. +Returns the extracted description or text unchanged." + (cj/org-link-get-description text)) + +;;; Normal Cases - Link with Description + +(ert-deftest test-link-with-description () + "Should extract description from link with description." + (let ((result (test-link-description "[[https://example.com][Example Site]]"))) + (should (string= result "Example Site")))) + +(ert-deftest test-link-with-multiword-description () + "Should extract multi-word description." + (let ((result (test-link-description "[[url][Multiple Word Description]]"))) + (should (string= result "Multiple Word Description")))) + +(ert-deftest test-link-with-special-chars-in-description () + "Should extract description with special characters." + (let ((result (test-link-description "[[url][Description: with, punctuation!]]"))) + (should (string= result "Description: with, punctuation!")))) + +(ert-deftest test-link-file-path-with-description () + "Should extract description from file link." + (let ((result (test-link-description "[[file:~/document.pdf][My Document]]"))) + (should (string= result "My Document")))) + +(ert-deftest test-link-with-numbers-in-description () + "Should extract description containing numbers." + (let ((result (test-link-description "[[url][Chapter 42]]"))) + (should (string= result "Chapter 42")))) + +;;; Normal Cases - Link without Description + +(ert-deftest test-link-without-description-url () + "Should return URL when no description is present." + (let ((result (test-link-description "[[https://example.com]]"))) + (should (string= result "https://example.com")))) + +(ert-deftest test-link-without-description-file () + "Should return file path when no description." + (let ((result (test-link-description "[[file:~/notes.org]]"))) + (should (string= result "file:~/notes.org")))) + +(ert-deftest test-link-without-description-id () + "Should return ID when no description." + (let ((result (test-link-description "[[id:abc123]]"))) + (should (string= result "id:abc123")))) + +;;; Normal Cases - No Link + +(ert-deftest test-plain-text () + "Should return plain text unchanged." + (let ((result (test-link-description "Plain text without link"))) + (should (string= result "Plain text without link")))) + +(ert-deftest test-text-with-brackets-but-not-link () + "Should return text with single brackets unchanged." + (let ((result (test-link-description "Text [with] brackets"))) + (should (string= result "Text [with] brackets")))) + +(ert-deftest test-text-with-partial-link-syntax () + "Should return text with partial link syntax unchanged." + (let ((result (test-link-description "[[incomplete link"))) + (should (string= result "[[incomplete link")))) + +;;; Boundary Cases - Multiple Links + +(ert-deftest test-multiple-links-extracts-first () + "Should extract description from first link only." + (let ((result (test-link-description "[[url1][First]] and [[url2][Second]]"))) + (should (string= result "First")))) + +(ert-deftest test-multiple-links-first-has-no-description () + "Should extract URL from first link when it has no description." + (let ((result (test-link-description "[[url1]] and [[url2][Second]]"))) + (should (string= result "url1")))) + +;;; Boundary Cases - Empty and Edge Cases + +(ert-deftest test-empty-string () + "Should return empty string unchanged." + (let ((result (test-link-description ""))) + (should (string= result "")))) + +(ert-deftest test-link-with-empty-description () + "Should return text unchanged when description brackets are empty." + (let ((result (test-link-description "[[https://example.com][]]"))) + ;; Regex requires at least one char in description, so no match + (should (string= result "[[https://example.com][]]")))) + +(ert-deftest test-link-with-empty-url () + "Should return text unchanged when link is completely empty." + (let ((result (test-link-description "[[]]"))) + ;; Regex requires at least one char in URL, so no match, returns unchanged + (should (string= result "[[]]")))) + +(ert-deftest test-link-with-empty-url-and-description () + "Should handle completely empty link." + (let ((result (test-link-description "[][]"))) + (should (string= result "[][]")))) + +;;; Edge Cases - Special Link Types + +(ert-deftest test-internal-link () + "Should extract description from internal link." + (let ((result (test-link-description "[[*Heading][My Heading]]"))) + (should (string= result "My Heading")))) + +(ert-deftest test-internal-link-without-description () + "Should return heading target from internal link without description." + (let ((result (test-link-description "[[*Heading]]"))) + (should (string= result "*Heading")))) + +(ert-deftest test-custom-id-link () + "Should handle custom ID links." + (let ((result (test-link-description "[[#custom-id][Custom Section]]"))) + (should (string= result "Custom Section")))) + +;;; Edge Cases - Link with Surrounding Text + +(ert-deftest test-link-with-prefix-text () + "Should extract description from link with prefix text." + (let ((result (test-link-description "See [[url][documentation]] for details"))) + (should (string= result "documentation")))) + +(ert-deftest test-link-at-start () + "Should extract description from link at start of text." + (let ((result (test-link-description "[[url][Link]] at beginning"))) + (should (string= result "Link")))) + +(ert-deftest test-link-at-end () + "Should extract description from link at end of text." + (let ((result (test-link-description "Text with [[url][link]]"))) + (should (string= result "link")))) + +;;; Edge Cases - Special Characters in URL + +(ert-deftest test-link-with-query-params () + "Should handle URL with query parameters." + (let ((result (test-link-description "[[https://example.com?q=test&foo=bar][Search]]"))) + (should (string= result "Search")))) + +(ert-deftest test-link-with-anchor () + "Should handle URL with anchor." + (let ((result (test-link-description "[[https://example.com#section][Section]]"))) + (should (string= result "Section")))) + +(ert-deftest test-link-with-spaces-in-description () + "Should preserve spaces in description." + (let ((result (test-link-description "[[url][Multiple Spaces]]"))) + (should (string= result "Multiple Spaces")))) + +(provide 'test-org-roam-config-link-description) +;;; test-org-roam-config-link-description.el ends here diff --git a/tests/test-org-roam-config-slug.el b/tests/test-org-roam-config-slug.el new file mode 100644 index 00000000..eb3149dd --- /dev/null +++ b/tests/test-org-roam-config-slug.el @@ -0,0 +1,223 @@ +;;; test-org-roam-config-slug.el --- Tests for cj/--generate-roam-slug -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--generate-roam-slug function from org-roam-config.el +;; +;; This function converts a title to a filename-safe slug by: +;; 1. Converting to lowercase +;; 2. Replacing non-alphanumeric characters with hyphens +;; 3. Removing leading and trailing hyphens +;; +;; Examples: +;; Input: "My Project Name" +;; Output: "my-project-name" +;; +;; Input: "Hello, World!" +;; Output: "hello-world" + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Now load the actual production module +(require 'org-roam-config) + +;;; Test Helpers + +(defun test-slug (title) + "Test cj/--generate-roam-slug on TITLE. +Returns the slugified string." + (cj/--generate-roam-slug title)) + +;;; Normal Cases - Simple Titles + +(ert-deftest test-slug-simple-word () + "Should return lowercase simple word." + (let ((result (test-slug "Hello"))) + (should (string= result "hello")))) + +(ert-deftest test-slug-multiple-words () + "Should replace spaces with hyphens." + (let ((result (test-slug "My Project Name"))) + (should (string= result "my-project-name")))) + +(ert-deftest test-slug-already-lowercase () + "Should handle already lowercase text." + (let ((result (test-slug "simple"))) + (should (string= result "simple")))) + +(ert-deftest test-slug-mixed-case () + "Should convert mixed case to lowercase." + (let ((result (test-slug "MixedCaseTitle"))) + (should (string= result "mixedcasetitle")))) + +;;; Normal Cases - Punctuation + +(ert-deftest test-slug-with-comma () + "Should remove commas." + (let ((result (test-slug "Hello, World"))) + (should (string= result "hello-world")))) + +(ert-deftest test-slug-with-period () + "Should remove periods." + (let ((result (test-slug "Version 2.0"))) + (should (string= result "version-2-0")))) + +(ert-deftest test-slug-with-exclamation () + "Should remove exclamation marks." + (let ((result (test-slug "Hello World!"))) + (should (string= result "hello-world")))) + +(ert-deftest test-slug-with-question () + "Should remove question marks." + (let ((result (test-slug "What Is This?"))) + (should (string= result "what-is-this")))) + +(ert-deftest test-slug-with-colon () + "Should remove colons." + (let ((result (test-slug "Note: Important"))) + (should (string= result "note-important")))) + +(ert-deftest test-slug-with-parentheses () + "Should remove parentheses." + (let ((result (test-slug "Item (copy)"))) + (should (string= result "item-copy")))) + +;;; Normal Cases - Numbers + +(ert-deftest test-slug-with-numbers () + "Should preserve numbers." + (let ((result (test-slug "Chapter 42"))) + (should (string= result "chapter-42")))) + +(ert-deftest test-slug-only-numbers () + "Should handle titles with only numbers." + (let ((result (test-slug "123"))) + (should (string= result "123")))) + +(ert-deftest test-slug-mixed-alphanumeric () + "Should preserve alphanumeric characters." + (let ((result (test-slug "Test123ABC"))) + (should (string= result "test123abc")))) + +;;; Boundary Cases - Multiple Consecutive Special Chars + +(ert-deftest test-slug-multiple-spaces () + "Should collapse multiple spaces into single hyphen." + (let ((result (test-slug "Hello World"))) + (should (string= result "hello-world")))) + +(ert-deftest test-slug-mixed-punctuation () + "Should collapse mixed punctuation into single hyphen." + (let ((result (test-slug "Hello, ... World!"))) + (should (string= result "hello-world")))) + +(ert-deftest test-slug-consecutive-hyphens () + "Should collapse consecutive hyphens." + (let ((result (test-slug "Hello---World"))) + (should (string= result "hello-world")))) + +;;; Boundary Cases - Leading/Trailing Special Chars + +(ert-deftest test-slug-leading-space () + "Should remove leading hyphen from leading space." + (let ((result (test-slug " Hello"))) + (should (string= result "hello")))) + +(ert-deftest test-slug-trailing-space () + "Should remove trailing hyphen from trailing space." + (let ((result (test-slug "Hello "))) + (should (string= result "hello")))) + +(ert-deftest test-slug-leading-punctuation () + "Should remove leading hyphen from leading punctuation." + (let ((result (test-slug "...Hello"))) + (should (string= result "hello")))) + +(ert-deftest test-slug-trailing-punctuation () + "Should remove trailing hyphen from trailing punctuation." + (let ((result (test-slug "Hello!!!"))) + (should (string= result "hello")))) + +(ert-deftest test-slug-leading-and-trailing () + "Should remove both leading and trailing hyphens." + (let ((result (test-slug " Hello World "))) + (should (string= result "hello-world")))) + +;;; Boundary Cases - Empty and Short + +(ert-deftest test-slug-empty-string () + "Should return empty string for empty input." + (let ((result (test-slug ""))) + (should (string= result "")))) + +(ert-deftest test-slug-only-punctuation () + "Should return empty string for only punctuation." + (let ((result (test-slug "!!!"))) + (should (string= result "")))) + +(ert-deftest test-slug-only-spaces () + "Should return empty string for only spaces." + (let ((result (test-slug " "))) + (should (string= result "")))) + +(ert-deftest test-slug-single-char () + "Should handle single character." + (let ((result (test-slug "A"))) + (should (string= result "a")))) + +;;; Edge Cases - Special Characters + +(ert-deftest test-slug-with-underscore () + "Should replace underscores with hyphens." + (let ((result (test-slug "my_variable_name"))) + (should (string= result "my-variable-name")))) + +(ert-deftest test-slug-with-slash () + "Should remove slashes." + (let ((result (test-slug "path/to/file"))) + (should (string= result "path-to-file")))) + +(ert-deftest test-slug-with-at-sign () + "Should remove at signs." + (let ((result (test-slug "user@example"))) + (should (string= result "user-example")))) + +(ert-deftest test-slug-with-hash () + "Should remove hash symbols." + (let ((result (test-slug "#hashtag"))) + (should (string= result "hashtag")))) + +(ert-deftest test-slug-with-dollar () + "Should remove dollar signs." + (let ((result (test-slug "$price"))) + (should (string= result "price")))) + +;;; Edge Cases - Unicode (if supported) + +(ert-deftest test-slug-with-unicode () + "Should remove unicode characters." + (let ((result (test-slug "Café"))) + (should (string= result "caf")))) + +(ert-deftest test-slug-with-emoji () + "Should remove emoji." + (let ((result (test-slug "Hello 😀 World"))) + (should (string= result "hello-world")))) + +;;; Edge Cases - Long Titles + +(ert-deftest test-slug-very-long-title () + "Should handle very long titles." + (let* ((long-title (mapconcat #'identity (make-list 20 "word") " ")) + (result (test-slug long-title))) + (should (string-prefix-p "word-" result)) + (should (string-suffix-p "-word" result)) + (should (not (string-match-p " " result))))) + +(provide 'test-org-roam-config-slug) +;;; test-org-roam-config-slug.el ends here |
