diff options
| -rw-r--r-- | modules/hugo-config.el | 39 | ||||
| -rw-r--r-- | tests/test-hugo-config-open-blog-dir-external.el | 105 | ||||
| -rw-r--r-- | tests/test-hugo-config-post-file-path.el | 99 | ||||
| -rw-r--r-- | tests/test-hugo-config-post-template.el | 120 | ||||
| -rw-r--r-- | tests/test-hugo-config-toggle-draft.el | 161 | ||||
| -rw-r--r-- | tests/test-modeline-config-string-cut-middle.el | 145 | ||||
| -rw-r--r-- | tests/test-modeline-config-string-truncate-p.el | 132 | ||||
| -rw-r--r-- | todo.org | 77 |
8 files changed, 846 insertions, 32 deletions
diff --git a/modules/hugo-config.el b/modules/hugo-config.el index 8bc2cef2..33245fd4 100644 --- a/modules/hugo-config.el +++ b/modules/hugo-config.el @@ -34,31 +34,42 @@ ;; ----------------------------- Hugo Blog Functions --------------------------- +(defun cj/hugo--post-file-path (title) + "Return the file path for a Hugo post with TITLE. +Generates a slug from TITLE using `org-hugo-slug' and returns +the full path under `cj/hugo-content-org-dir'. +Assumes ox-hugo is already loaded (via use-package declaration above)." + (let ((slug (org-hugo-slug title))) + (expand-file-name (concat slug ".org") cj/hugo-content-org-dir))) + +(defun cj/hugo--post-template (title date) + "Return the Org front matter template for a Hugo post. +TITLE is the post title, DATE is the date string (YYYY-MM-DD)." + (format "#+hugo_base_dir: ../../ +#+hugo_section: log +#+hugo_auto_set_lastmod: t +#+title: %s +#+date: %s +#+hugo_tags: +#+hugo_draft: true +#+hugo_custom_front_matter: :description \"\" + +" title date)) + (defun cj/hugo-new-post () "Create a new Hugo blog post as a standalone Org file. Prompts for title, generates the slug filename, and opens the new file with Hugo front matter keywords pre-filled." (interactive) - (require 'ox-hugo) (let* ((title (read-from-minibuffer "Post Title: ")) - (slug (org-hugo-slug title)) - (date (format-time-string "%Y-%m-%d")) - (file (expand-file-name (concat slug ".org") cj/hugo-content-org-dir))) + (file (cj/hugo--post-file-path title)) + (date (format-time-string "%Y-%m-%d"))) (when (file-exists-p file) (user-error "Post already exists: %s" file)) (unless (file-directory-p cj/hugo-content-org-dir) (make-directory cj/hugo-content-org-dir t)) (find-file file) - (insert (format "#+hugo_base_dir: ../../ -#+hugo_section: log -#+hugo_auto_set_lastmod: t -#+title: %s -#+date: %s -#+hugo_tags: -#+hugo_draft: true -#+hugo_custom_front_matter: :description \"\" - -" title date)) + (insert (cj/hugo--post-template title date)) (save-buffer) (message "New post: %s" file))) diff --git a/tests/test-hugo-config-open-blog-dir-external.el b/tests/test-hugo-config-open-blog-dir-external.el new file mode 100644 index 00000000..ae4a25ba --- /dev/null +++ b/tests/test-hugo-config-open-blog-dir-external.el @@ -0,0 +1,105 @@ +;;; test-hugo-config-open-blog-dir-external.el --- Tests for cj/hugo-open-blog-dir-external -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/hugo-open-blog-dir-external function from hugo-config.el +;; +;; This function opens the blog source directory in the system file manager, +;; selecting the command based on platform: +;; - macOS: "open" +;; - Windows: "explorer.exe" +;; - Linux/other: "xdg-open" +;; +;; We mock the platform detection functions and start-process to verify +;; the correct command is dispatched per platform. + +;;; Code: + +(require 'ert) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(unless (boundp 'website-dir) + (defvar website-dir "/tmp/test-website/")) +(unless (fboundp 'env-macos-p) + (defun env-macos-p () nil)) +(unless (fboundp 'env-windows-p) + (defun env-windows-p () nil)) + +(require 'hugo-config) + +;;; Test Helpers + +(defvar test-hugo--captured-process-cmd nil + "Captures the command passed to start-process during tests.") + +(defmacro with-platform (macos-p windows-p &rest body) + "Execute BODY with mocked platform detection. +MACOS-P and WINDOWS-P control env-macos-p and env-windows-p return values. +Mocks start-process to capture the command and file-directory-p to avoid +filesystem checks." + (declare (indent 2)) + `(let ((test-hugo--captured-process-cmd nil)) + (cl-letf (((symbol-function 'env-macos-p) (lambda () ,macos-p)) + ((symbol-function 'env-windows-p) (lambda () ,windows-p)) + ((symbol-function 'file-directory-p) (lambda (_d) t)) + ((symbol-function 'start-process) + (lambda (_name _buf cmd &rest _args) + (setq test-hugo--captured-process-cmd cmd)))) + ,@body))) + +;;; Normal Cases + +(ert-deftest test-hugo-config-open-blog-dir-external-normal-linux () + "Should use xdg-open on Linux." + (with-platform nil nil + (cj/hugo-open-blog-dir-external) + (should (string= test-hugo--captured-process-cmd "xdg-open")))) + +(ert-deftest test-hugo-config-open-blog-dir-external-normal-macos () + "Should use open on macOS." + (with-platform t nil + (cj/hugo-open-blog-dir-external) + (should (string= test-hugo--captured-process-cmd "open")))) + +(ert-deftest test-hugo-config-open-blog-dir-external-normal-windows () + "Should use explorer.exe on Windows." + (with-platform nil t + (cj/hugo-open-blog-dir-external) + (should (string= test-hugo--captured-process-cmd "explorer.exe")))) + +;;; Boundary Cases + +(ert-deftest test-hugo-config-open-blog-dir-external-boundary-macos-takes-precedence () + "When both macos and windows return true, macOS should take precedence." + (with-platform t t + (cj/hugo-open-blog-dir-external) + (should (string= test-hugo--captured-process-cmd "open")))) + +(ert-deftest test-hugo-config-open-blog-dir-external-boundary-creates-missing-dir () + "Should create the directory if it doesn't exist." + (let ((mkdir-called nil)) + (cl-letf (((symbol-function 'env-macos-p) (lambda () nil)) + ((symbol-function 'env-windows-p) (lambda () nil)) + ((symbol-function 'file-directory-p) (lambda (_d) nil)) + ((symbol-function 'make-directory) + (lambda (_dir &rest _args) (setq mkdir-called t))) + ((symbol-function 'start-process) #'ignore)) + (cj/hugo-open-blog-dir-external) + (should mkdir-called)))) + +(ert-deftest test-hugo-config-open-blog-dir-external-boundary-skips-mkdir-when-exists () + "Should not call make-directory if directory already exists." + (let ((mkdir-called nil)) + (cl-letf (((symbol-function 'env-macos-p) (lambda () nil)) + ((symbol-function 'env-windows-p) (lambda () nil)) + ((symbol-function 'file-directory-p) (lambda (_d) t)) + ((symbol-function 'make-directory) + (lambda (_dir &rest _args) (setq mkdir-called t))) + ((symbol-function 'start-process) #'ignore)) + (cj/hugo-open-blog-dir-external) + (should-not mkdir-called)))) + +(provide 'test-hugo-config-open-blog-dir-external) +;;; test-hugo-config-open-blog-dir-external.el ends here diff --git a/tests/test-hugo-config-post-file-path.el b/tests/test-hugo-config-post-file-path.el new file mode 100644 index 00000000..ac6bc692 --- /dev/null +++ b/tests/test-hugo-config-post-file-path.el @@ -0,0 +1,99 @@ +;;; test-hugo-config-post-file-path.el --- Tests for cj/hugo--post-file-path -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/hugo--post-file-path function from hugo-config.el +;; +;; This function takes a post title, generates a slug via org-hugo-slug, +;; and returns the full file path under cj/hugo-content-org-dir. +;; +;; We mock org-hugo-slug to isolate our path construction logic from +;; the ox-hugo package (external dependency). + +;;; Code: + +(require 'ert) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(unless (boundp 'website-dir) + (defvar website-dir "/tmp/test-website/")) +(unless (fboundp 'env-macos-p) + (defun env-macos-p () nil)) +(unless (fboundp 'env-windows-p) + (defun env-windows-p () nil)) +(unless (fboundp 'org-hugo-slug) + (defun org-hugo-slug (title) title)) + +;; Stub ox-hugo require since MELPA packages aren't available in batch +(provide 'ox-hugo) + +(require 'hugo-config) + +;;; Normal Cases + +(ert-deftest test-hugo-config-post-file-path-normal-simple-title () + "Should construct path from slugified title." + (cl-letf (((symbol-function 'org-hugo-slug) + (lambda (title) "my-first-post"))) + (let ((result (cj/hugo--post-file-path "My First Post"))) + (should (string-suffix-p "my-first-post.org" result))))) + +(ert-deftest test-hugo-config-post-file-path-normal-under-content-org-dir () + "Should place the file under cj/hugo-content-org-dir." + (cl-letf (((symbol-function 'org-hugo-slug) + (lambda (title) "test-post"))) + (let ((result (cj/hugo--post-file-path "Test Post"))) + (should (string-prefix-p (expand-file-name cj/hugo-content-org-dir) result))))) + +(ert-deftest test-hugo-config-post-file-path-normal-org-extension () + "Should always produce a .org file." + (cl-letf (((symbol-function 'org-hugo-slug) + (lambda (title) "any-slug"))) + (let ((result (cj/hugo--post-file-path "Any Title"))) + (should (string-suffix-p ".org" result))))) + +(ert-deftest test-hugo-config-post-file-path-normal-different-titles () + "Different titles should produce different file paths." + (cl-letf (((symbol-function 'org-hugo-slug) + (lambda (title) + (if (string= title "Alpha") "alpha" "beta")))) + (let ((path-a (cj/hugo--post-file-path "Alpha")) + (path-b (cj/hugo--post-file-path "Beta"))) + (should-not (string= path-a path-b))))) + +;;; Boundary Cases + +(ert-deftest test-hugo-config-post-file-path-boundary-title-with-special-chars () + "Should handle titles with special characters via slug." + (cl-letf (((symbol-function 'org-hugo-slug) + (lambda (title) "whats-new-in-2026"))) + (let ((result (cj/hugo--post-file-path "What's New in 2026?!"))) + (should (string-suffix-p "whats-new-in-2026.org" result))))) + +(ert-deftest test-hugo-config-post-file-path-boundary-single-word-title () + "Should handle single word title." + (cl-letf (((symbol-function 'org-hugo-slug) + (lambda (title) "hello"))) + (let ((result (cj/hugo--post-file-path "Hello"))) + (should (string-suffix-p "hello.org" result))))) + +(ert-deftest test-hugo-config-post-file-path-boundary-very-long-title () + "Should handle a very long title." + (cl-letf (((symbol-function 'org-hugo-slug) + (lambda (title) "this-is-a-very-long-title-that-goes-on-and-on"))) + (let ((result (cj/hugo--post-file-path "This Is A Very Long Title That Goes On And On"))) + (should (string-suffix-p "this-is-a-very-long-title-that-goes-on-and-on.org" result))))) + +;;; Error Cases + +(ert-deftest test-hugo-config-post-file-path-error-empty-title () + "Empty title should still produce a valid path (slug handles it)." + (cl-letf (((symbol-function 'org-hugo-slug) + (lambda (title) ""))) + (let ((result (cj/hugo--post-file-path ""))) + (should (string-suffix-p ".org" result))))) + +(provide 'test-hugo-config-post-file-path) +;;; test-hugo-config-post-file-path.el ends here diff --git a/tests/test-hugo-config-post-template.el b/tests/test-hugo-config-post-template.el new file mode 100644 index 00000000..a464c30b --- /dev/null +++ b/tests/test-hugo-config-post-template.el @@ -0,0 +1,120 @@ +;;; test-hugo-config-post-template.el --- Tests for cj/hugo--post-template -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/hugo--post-template function from hugo-config.el +;; +;; This function generates the Org front matter template for a Hugo post. +;; It takes a title and date, and returns the complete template string +;; with all required Hugo keywords. + +;;; Code: + +(require 'ert) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(unless (boundp 'website-dir) + (defvar website-dir "/tmp/test-website/")) +(unless (fboundp 'env-macos-p) + (defun env-macos-p () nil)) +(unless (fboundp 'env-windows-p) + (defun env-windows-p () nil)) + +(require 'hugo-config) + +;;; Normal Cases + +(ert-deftest test-hugo-config-post-template-normal-contains-title () + "Template should contain the provided title." + (let ((result (cj/hugo--post-template "My First Post" "2026-02-14"))) + (should (string-match-p "#\\+title: My First Post" result)))) + +(ert-deftest test-hugo-config-post-template-normal-contains-date () + "Template should contain the provided date." + (let ((result (cj/hugo--post-template "Test" "2026-02-14"))) + (should (string-match-p "#\\+date: 2026-02-14" result)))) + +(ert-deftest test-hugo-config-post-template-normal-contains-base-dir () + "Template should contain hugo_base_dir pointing to site root." + (let ((result (cj/hugo--post-template "Test" "2026-02-14"))) + (should (string-match-p "#\\+hugo_base_dir: \\.\\./\\.\\." result)))) + +(ert-deftest test-hugo-config-post-template-normal-contains-section () + "Template should set hugo_section to log." + (let ((result (cj/hugo--post-template "Test" "2026-02-14"))) + (should (string-match-p "#\\+hugo_section: log" result)))) + +(ert-deftest test-hugo-config-post-template-normal-draft-true () + "New posts should default to draft: true." + (let ((result (cj/hugo--post-template "Test" "2026-02-14"))) + (should (string-match-p "#\\+hugo_draft: true" result)))) + +(ert-deftest test-hugo-config-post-template-normal-has-lastmod () + "Template should enable auto-set lastmod." + (let ((result (cj/hugo--post-template "Test" "2026-02-14"))) + (should (string-match-p "#\\+hugo_auto_set_lastmod: t" result)))) + +(ert-deftest test-hugo-config-post-template-normal-has-tags-placeholder () + "Template should include empty hugo_tags keyword." + (let ((result (cj/hugo--post-template "Test" "2026-02-14"))) + (should (string-match-p "#\\+hugo_tags:" result)))) + +(ert-deftest test-hugo-config-post-template-normal-has-description () + "Template should include description custom front matter." + (let ((result (cj/hugo--post-template "Test" "2026-02-14"))) + (should (string-match-p "#\\+hugo_custom_front_matter: :description" result)))) + +(ert-deftest test-hugo-config-post-template-normal-ends-with-blank-line () + "Template should end with a blank line for content insertion." + (let ((result (cj/hugo--post-template "Test" "2026-02-14"))) + (should (string-suffix-p "\n\n" result)))) + +;;; Boundary Cases + +(ert-deftest test-hugo-config-post-template-boundary-title-with-quotes () + "Title containing quotes should be inserted literally." + (let ((result (cj/hugo--post-template "It's a \"Test\"" "2026-02-14"))) + (should (string-match-p "#\\+title: It's a \"Test\"" result)))) + +(ert-deftest test-hugo-config-post-template-boundary-title-with-colons () + "Title with colons should be inserted literally." + (let ((result (cj/hugo--post-template "Part 1: The Beginning" "2026-02-14"))) + (should (string-match-p "#\\+title: Part 1: The Beginning" result)))) + +(ert-deftest test-hugo-config-post-template-boundary-empty-title () + "Empty title should produce valid template with empty title line." + (let ((result (cj/hugo--post-template "" "2026-02-14"))) + (should (string-match-p "#\\+title: \n" result)) + (should (string-match-p "#\\+date: 2026-02-14" result)))) + +(ert-deftest test-hugo-config-post-template-boundary-keyword-order () + "Keywords should appear in the expected order." + (let* ((result (cj/hugo--post-template "Test" "2026-02-14")) + (pos-base (string-match "#\\+hugo_base_dir" result)) + (pos-section (string-match "#\\+hugo_section" result)) + (pos-title (string-match "#\\+title" result)) + (pos-date (string-match "#\\+date" result)) + (pos-draft (string-match "#\\+hugo_draft" result))) + (should (< pos-base pos-section)) + (should (< pos-section pos-title)) + (should (< pos-title pos-date)) + (should (< pos-date pos-draft)))) + +;;; Error Cases + +(ert-deftest test-hugo-config-post-template-error-nil-title () + "Nil title should produce template with 'nil' string (format behavior)." + (let ((result (cj/hugo--post-template nil "2026-02-14"))) + (should (stringp result)) + (should (string-match-p "#\\+date: 2026-02-14" result)))) + +(ert-deftest test-hugo-config-post-template-error-nil-date () + "Nil date should produce template with 'nil' string (format behavior)." + (let ((result (cj/hugo--post-template "Test" nil))) + (should (stringp result)) + (should (string-match-p "#\\+title: Test" result)))) + +(provide 'test-hugo-config-post-template) +;;; test-hugo-config-post-template.el ends here diff --git a/tests/test-hugo-config-toggle-draft.el b/tests/test-hugo-config-toggle-draft.el new file mode 100644 index 00000000..2e75a7c9 --- /dev/null +++ b/tests/test-hugo-config-toggle-draft.el @@ -0,0 +1,161 @@ +;;; test-hugo-config-toggle-draft.el --- Tests for cj/hugo-toggle-draft -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/hugo-toggle-draft function from hugo-config.el +;; +;; This function toggles the #+hugo_draft keyword between "true" and "false" +;; in the current buffer, using regex search and replace-match. +;; +;; We test the buffer manipulation logic directly using temp buffers, +;; mocking only save-buffer to avoid file I/O. + +;;; Code: + +(require 'ert) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(unless (boundp 'website-dir) + (defvar website-dir "/tmp/test-website/")) +(unless (fboundp 'env-macos-p) + (defun env-macos-p () nil)) +(unless (fboundp 'env-windows-p) + (defun env-windows-p () nil)) + +(require 'hugo-config) + +;;; Test Helpers + +(defun test-hugo-toggle-draft-in-buffer (content) + "Run cj/hugo-toggle-draft on buffer with CONTENT. +Returns the buffer string after toggling. Mocks save-buffer." + (with-temp-buffer + (insert content) + (cl-letf (((symbol-function 'save-buffer) #'ignore)) + (cj/hugo-toggle-draft)) + (buffer-string))) + +(defconst test-hugo-draft-true-post + "#+hugo_base_dir: ../../ +#+hugo_section: log +#+hugo_auto_set_lastmod: t +#+title: Test Post +#+date: 2026-02-14 +#+hugo_tags: test +#+hugo_draft: true +#+hugo_custom_front_matter: :description \"A test post.\" + +Some content here. +" + "Sample post with draft set to true.") + +(defconst test-hugo-draft-false-post + "#+hugo_base_dir: ../../ +#+hugo_section: log +#+hugo_auto_set_lastmod: t +#+title: Test Post +#+date: 2026-02-14 +#+hugo_tags: test +#+hugo_draft: false +#+hugo_custom_front_matter: :description \"A test post.\" + +Some content here. +" + "Sample post with draft set to false.") + +;;; Normal Cases + +(ert-deftest test-hugo-config-toggle-draft-normal-true-to-false () + "Should toggle draft from true to false." + (let ((result (test-hugo-toggle-draft-in-buffer test-hugo-draft-true-post))) + (should (string-match-p "#\\+hugo_draft: false" result)) + (should-not (string-match-p "#\\+hugo_draft: true" result)))) + +(ert-deftest test-hugo-config-toggle-draft-normal-false-to-true () + "Should toggle draft from false to true." + (let ((result (test-hugo-toggle-draft-in-buffer test-hugo-draft-false-post))) + (should (string-match-p "#\\+hugo_draft: true" result)) + (should-not (string-match-p "#\\+hugo_draft: false" result)))) + +(ert-deftest test-hugo-config-toggle-draft-normal-preserves-other-content () + "Should not modify any other lines in the buffer." + (let ((result (test-hugo-toggle-draft-in-buffer test-hugo-draft-true-post))) + (should (string-match-p "#\\+title: Test Post" result)) + (should (string-match-p "#\\+hugo_tags: test" result)) + (should (string-match-p "Some content here\\." result)))) + +(ert-deftest test-hugo-config-toggle-draft-normal-roundtrip () + "Toggling twice should return to original state." + (let* ((first (test-hugo-toggle-draft-in-buffer test-hugo-draft-true-post)) + (second (with-temp-buffer + (insert first) + (cl-letf (((symbol-function 'save-buffer) #'ignore)) + (cj/hugo-toggle-draft)) + (buffer-string)))) + (should (string= second test-hugo-draft-true-post)))) + +;;; Boundary Cases + +(ert-deftest test-hugo-config-toggle-draft-boundary-extra-spaces () + "Should handle extra spaces between keyword and value." + (let ((result (test-hugo-toggle-draft-in-buffer "#+hugo_draft: true\n"))) + (should (string-match-p "#\\+hugo_draft: false" result)))) + +(ert-deftest test-hugo-config-toggle-draft-boundary-keyword-first-line () + "Should work when #+hugo_draft is the first line." + (let ((result (test-hugo-toggle-draft-in-buffer "#+hugo_draft: true\nContent."))) + (should (string-match-p "#\\+hugo_draft: false" result)) + (should (string-match-p "Content\\." result)))) + +(ert-deftest test-hugo-config-toggle-draft-boundary-keyword-last-line () + "Should work when #+hugo_draft is the last line." + (let ((result (test-hugo-toggle-draft-in-buffer "#+title: Test\n#+hugo_draft: false"))) + (should (string-match-p "#\\+hugo_draft: true" result)))) + +(ert-deftest test-hugo-config-toggle-draft-boundary-only-draft-line () + "Should work when buffer contains only the draft keyword." + (let ((result (test-hugo-toggle-draft-in-buffer "#+hugo_draft: true"))) + (should (string= result "#+hugo_draft: false")))) + +(ert-deftest test-hugo-config-toggle-draft-boundary-preserves-point () + "Point should be restored to original position after toggle." + (with-temp-buffer + (insert test-hugo-draft-true-post) + (goto-char 50) + (let ((original-point (point))) + (cl-letf (((symbol-function 'save-buffer) #'ignore)) + (cj/hugo-toggle-draft)) + (should (= (point) original-point))))) + +;;; Error Cases + +(ert-deftest test-hugo-config-toggle-draft-error-no-keyword () + "Should signal user-error when no #+hugo_draft keyword is present." + (should-error + (with-temp-buffer + (insert "#+title: A Post Without Draft\n#+hugo_tags: test\n") + (cl-letf (((symbol-function 'save-buffer) #'ignore)) + (cj/hugo-toggle-draft))) + :type 'user-error)) + +(ert-deftest test-hugo-config-toggle-draft-error-empty-buffer () + "Should signal user-error on empty buffer." + (should-error + (with-temp-buffer + (cl-letf (((symbol-function 'save-buffer) #'ignore)) + (cj/hugo-toggle-draft))) + :type 'user-error)) + +(ert-deftest test-hugo-config-toggle-draft-error-invalid-value () + "Should signal user-error when draft has unexpected value." + (should-error + (with-temp-buffer + (insert "#+hugo_draft: maybe\n") + (cl-letf (((symbol-function 'save-buffer) #'ignore)) + (cj/hugo-toggle-draft))) + :type 'user-error)) + +(provide 'test-hugo-config-toggle-draft) +;;; test-hugo-config-toggle-draft.el ends here diff --git a/tests/test-modeline-config-string-cut-middle.el b/tests/test-modeline-config-string-cut-middle.el new file mode 100644 index 00000000..40cc0bcc --- /dev/null +++ b/tests/test-modeline-config-string-cut-middle.el @@ -0,0 +1,145 @@ +;;; test-modeline-config-string-cut-middle.el --- Tests for cj/modeline-string-cut-middle -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/modeline-string-cut-middle function from modeline-config.el +;; +;; This function truncates a string in the middle with "..." when conditions +;; are met (narrow window, multi-window, string exceeds truncation length). +;; Example: "my-very-long-name.el" → "my-ver...me.el" +;; +;; We mock cj/modeline-string-truncate-p to isolate the string manipulation +;; logic from window state checks. + +;;; Code: + +(require 'ert) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(unless (boundp 'cj/buffer-status-colors) + (defvar cj/buffer-status-colors + '((unmodified . "#FFFFFF") + (modified . "#00FF00") + (read-only . "#FF0000") + (overwrite . "#FFD700")))) + +(require 'modeline-config) + +;;; Test Helpers + +(defmacro with-truncation-enabled (&rest body) + "Execute BODY with truncation forced on. +Mocks `cj/modeline-string-truncate-p' to always return t." + `(cl-letf (((symbol-function 'cj/modeline-string-truncate-p) + (lambda (_str) t))) + ,@body)) + +(defmacro with-truncation-disabled (&rest body) + "Execute BODY with truncation forced off. +Mocks `cj/modeline-string-truncate-p' to always return nil." + `(cl-letf (((symbol-function 'cj/modeline-string-truncate-p) + (lambda (_str) nil))) + ,@body)) + +;;; Normal Cases + +(ert-deftest test-modeline-config-string-cut-middle-normal-truncates-long-string () + "Should truncate a long string in the middle with '...'." + (with-truncation-enabled + (let* ((cj/modeline-string-truncate-length 12) + (result (cj/modeline-string-cut-middle "my-very-long-name.el"))) + (should (string-match-p "\\.\\.\\." result)) + (should (< (length result) (length "my-very-long-name.el")))))) + +(ert-deftest test-modeline-config-string-cut-middle-normal-preserves-start-and-end () + "Should keep the beginning and end of the string." + (with-truncation-enabled + (let* ((cj/modeline-string-truncate-length 12) + (result (cj/modeline-string-cut-middle "my-very-long-name.el"))) + (should (string-prefix-p "my-ver" result)) + (should (string-suffix-p "me.el" result))))) + +(ert-deftest test-modeline-config-string-cut-middle-normal-result-length () + "Truncated result should be truncate-length + 3 (for '...')." + (with-truncation-enabled + (let* ((cj/modeline-string-truncate-length 12) + (result (cj/modeline-string-cut-middle "abcdefghijklmnopqrstuvwxyz"))) + ;; half=6, so 6 chars + "..." + 6 chars = 15 + (should (= (length result) 15))))) + +(ert-deftest test-modeline-config-string-cut-middle-normal-no-truncation-when-disabled () + "Should return string unchanged when truncation is disabled." + (with-truncation-disabled + (let ((result (cj/modeline-string-cut-middle "my-very-long-name.el"))) + (should (string= result "my-very-long-name.el"))))) + +(ert-deftest test-modeline-config-string-cut-middle-normal-short-string-unchanged () + "Short string should pass through unchanged when truncation is disabled." + (with-truncation-disabled + (let ((result (cj/modeline-string-cut-middle "init.el"))) + (should (string= result "init.el"))))) + +;;; Boundary Cases + +(ert-deftest test-modeline-config-string-cut-middle-boundary-empty-string () + "Empty string should pass through unchanged." + (with-truncation-disabled + (let ((result (cj/modeline-string-cut-middle ""))) + (should (string= result ""))))) + +(ert-deftest test-modeline-config-string-cut-middle-boundary-single-char () + "Single character string should pass through unchanged." + (with-truncation-disabled + (let ((result (cj/modeline-string-cut-middle "x"))) + (should (string= result "x"))))) + +(ert-deftest test-modeline-config-string-cut-middle-boundary-odd-truncate-length () + "Odd truncation length should floor the half correctly." + (with-truncation-enabled + (let* ((cj/modeline-string-truncate-length 11) + (result (cj/modeline-string-cut-middle "abcdefghijklmnopqrstuvwxyz"))) + ;; half = (floor 11 2) = 5, so 5 + "..." + 5 = 13 + (should (= (length result) 13)) + (should (string= (substring result 0 5) "abcde")) + (should (string= (substring result -5) "vwxyz"))))) + +(ert-deftest test-modeline-config-string-cut-middle-boundary-truncate-length-2 () + "Minimum practical truncation length of 2." + (with-truncation-enabled + (let* ((cj/modeline-string-truncate-length 2) + (result (cj/modeline-string-cut-middle "abcdefghij"))) + ;; half = (floor 2 2) = 1, so 1 + "..." + 1 = 5 + (should (= (length result) 5)) + (should (string= result "a...j"))))) + +(ert-deftest test-modeline-config-string-cut-middle-boundary-unicode-filename () + "Should handle unicode characters in filename." + (with-truncation-enabled + (let* ((cj/modeline-string-truncate-length 10) + (result (cj/modeline-string-cut-middle "überlangerDateiname.el"))) + (should (string-match-p "\\.\\.\\." result)) + (should (< (length result) (length "überlangerDateiname.el")))))) + +(ert-deftest test-modeline-config-string-cut-middle-boundary-dots-in-filename () + "Should handle filenames with dots (common in elisp)." + (with-truncation-enabled + (let* ((cj/modeline-string-truncate-length 12) + (result (cj/modeline-string-cut-middle "test-custom-whitespace-collapse.el"))) + (should (string-match-p "\\.\\.\\." result))))) + +;;; Error Cases + +(ert-deftest test-modeline-config-string-cut-middle-error-nil-input () + "Nil input should pass through (truncate-p returns nil for non-strings)." + (with-truncation-disabled + (should (null (cj/modeline-string-cut-middle nil))))) + +(ert-deftest test-modeline-config-string-cut-middle-error-number-input () + "Number input should pass through (truncate-p returns nil for non-strings)." + (with-truncation-disabled + (should (= (cj/modeline-string-cut-middle 42) 42)))) + +(provide 'test-modeline-config-string-cut-middle) +;;; test-modeline-config-string-cut-middle.el ends here diff --git a/tests/test-modeline-config-string-truncate-p.el b/tests/test-modeline-config-string-truncate-p.el new file mode 100644 index 00000000..09378b0d --- /dev/null +++ b/tests/test-modeline-config-string-truncate-p.el @@ -0,0 +1,132 @@ +;;; test-modeline-config-string-truncate-p.el --- Tests for cj/modeline-string-truncate-p -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/modeline-string-truncate-p function from modeline-config.el +;; +;; This function returns non-nil when ALL conditions are met: +;; 1. STR is a string +;; 2. STR is not empty +;; 3. Window is narrow (< 100 chars via cj/modeline-window-narrow-p) +;; 4. String length exceeds cj/modeline-string-truncate-length +;; 5. Not in a single-window frame (one-window-p returns nil) +;; +;; We mock window functions to isolate the logic. + +;;; Code: + +(require 'ert) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(unless (boundp 'cj/buffer-status-colors) + (defvar cj/buffer-status-colors + '((unmodified . "#FFFFFF") + (modified . "#00FF00") + (read-only . "#FF0000") + (overwrite . "#FFD700")))) + +(require 'modeline-config) + +;;; Test Helpers + +(defmacro with-window-state (narrow-p one-window-p &rest body) + "Execute BODY with mocked window state. +NARROW-P controls `cj/modeline-window-narrow-p' return value. +ONE-WINDOW-P controls `one-window-p' return value." + (declare (indent 2)) + `(cl-letf (((symbol-function 'cj/modeline-window-narrow-p) + (lambda () ,narrow-p)) + ((symbol-function 'one-window-p) + (lambda (&rest _args) ,one-window-p))) + ,@body)) + +;;; Normal Cases - All conditions met (should return non-nil) + +(ert-deftest test-modeline-config-string-truncate-p-normal-long-string-narrow-multi-window () + "Should return non-nil when string is long, window is narrow, and multiple windows." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 12)) + (should (cj/modeline-string-truncate-p "a-very-long-filename.el"))))) + +(ert-deftest test-modeline-config-string-truncate-p-normal-exactly-over-length () + "Should return non-nil when string is one char over truncation length." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 12)) + (should (cj/modeline-string-truncate-p "1234567890123"))))) ; 13 chars + +;;; Normal Cases - Conditions not met (should return nil) + +(ert-deftest test-modeline-config-string-truncate-p-normal-short-string () + "Should return nil when string is shorter than truncation length." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 12)) + (should-not (cj/modeline-string-truncate-p "init.el"))))) + +(ert-deftest test-modeline-config-string-truncate-p-normal-wide-window () + "Should return nil when window is wide (not narrow)." + (with-window-state nil nil + (let ((cj/modeline-string-truncate-length 12)) + (should-not (cj/modeline-string-truncate-p "a-very-long-filename.el"))))) + +(ert-deftest test-modeline-config-string-truncate-p-normal-single-window () + "Should return nil when only one window is open." + (with-window-state t t + (let ((cj/modeline-string-truncate-length 12)) + (should-not (cj/modeline-string-truncate-p "a-very-long-filename.el"))))) + +;;; Boundary Cases + +(ert-deftest test-modeline-config-string-truncate-p-boundary-exact-length () + "Should return nil when string is exactly at truncation length." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 12)) + (should-not (cj/modeline-string-truncate-p "123456789012"))))) ; exactly 12 + +(ert-deftest test-modeline-config-string-truncate-p-boundary-empty-string () + "Should return nil for empty string." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 12)) + (should-not (cj/modeline-string-truncate-p ""))))) + +(ert-deftest test-modeline-config-string-truncate-p-boundary-single-char () + "Should return nil for single character string." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 12)) + (should-not (cj/modeline-string-truncate-p "x"))))) + +(ert-deftest test-modeline-config-string-truncate-p-boundary-truncate-length-1 () + "Should return non-nil for any string > 1 when truncation length is 1." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 1)) + (should (cj/modeline-string-truncate-p "ab"))))) + +(ert-deftest test-modeline-config-string-truncate-p-boundary-truncate-length-0 () + "Should return non-nil for any non-empty string when truncation length is 0." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 0)) + (should (cj/modeline-string-truncate-p "a"))))) + +;;; Error Cases + +(ert-deftest test-modeline-config-string-truncate-p-error-nil-input () + "Should return nil for nil input." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 12)) + (should-not (cj/modeline-string-truncate-p nil))))) + +(ert-deftest test-modeline-config-string-truncate-p-error-number-input () + "Should return nil for number input." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 12)) + (should-not (cj/modeline-string-truncate-p 42))))) + +(ert-deftest test-modeline-config-string-truncate-p-error-symbol-input () + "Should return nil for symbol input." + (with-window-state t nil + (let ((cj/modeline-string-truncate-length 12)) + (should-not (cj/modeline-string-truncate-p 'some-symbol))))) + +(provide 'test-modeline-config-string-truncate-p) +;;; test-modeline-config-string-truncate-p.el ends here @@ -31,7 +31,7 @@ Daily workflow improvement. THE BOTTLENECK. Currently 30+ seconds, target < 5 seconds. Use M-x profiler-start before Method 3 debug-profiling.el is built. -** TODO [#A] Optimize org-capture target building performance +** TODO [#B] Optimize org-capture target building performance 15-20 seconds every time capturing a task (12+ times/day). Major daily bottleneck - minutes lost waiting, plus context switching cost. @@ -464,26 +464,23 @@ Commits: - 0a69c58: test: Add comprehensive test suite for video-audio-recording module - b086539: style: Fix checkdoc warnings in video-audio-recording.el -*** TODO [#C] Add device testing command cj/recording-test-devices +*** DONE [#C] Add device testing command cj/recording-test-devices +CLOSED: [2026-02-06 Thu] Records 3 seconds of audio. Plays it back. Confirms devices work before real recording. -*** TODO [#C] Add recording status display (optional via flag, default off) -Show "Recording: 00:05:23" in modeline or echo area. -Timer showing duration. -File size updating. +*** CANCELLED [#C] Add recording status display (optional via flag, default off) +CLOSED: [2026-02-14 Sat] +Emoji modeline icon is sufficient. -*** TODO [#C] Add recording presets -Screencast (video + audio, high quality). -Podcast (audio only, voice optimized). -Meeting (balanced, lower filesize). -Quick note (audio, low quality, small file). +*** CANCELLED [#C] Add recording presets +CLOSED: [2026-02-14 Sat] +Only used for meetings; emoji icon is good-enough reminder. Gold-plating. -*** TODO [#C] Build recording history buffer -*Recordings* buffer showing history. -Duration, file size, location. -Quick actions: play, delete, rename, move. +*** CANCELLED [#C] Build recording history buffer +CLOSED: [2026-02-14 Sat] +Opening recordings in dirvish is sufficient. *** TODO [#C] Add post-processing hooks Auto-compress after recording. @@ -831,8 +828,49 @@ CLOSED: [2025-11-08 Fri] File modified: modules/flyspell-and-abbrev.el:235-251 * Method 2: Stop Problems Before They Appear [4/8] -** TODO [#B] Write Complete ERT Tests for This Config [0/0] -Unit and Integration Tests should be added as subtasks below, marked done when complete +** TODO [#B] Write Complete ERT Tests for This Config [0/31] +Unit and Integration Tests should be added as subtasks below, marked done when complete. + +*High-value test targets (no coverage, testable logic, daily use):* + +*** TODO custom-case — pure case conversion functions (upper/lower/title) +*** TODO custom-datetime — date/timestamp insertion and formatting +*** TODO host-environment — platform detection (env-macos-p, env-wayland-p, etc.) +*** TODO hugo-config — draft toggle, slug generation, post template +*** TODO org-capture-config — template building (relates to capture perf optimization) +*** TODO modeline-config — custom segment construction +*** TODO external-open — file-type detection and external app dispatch +*** TODO reconcile-open-repos — dirty repo scanning logic +*** TODO media-utils — URL download/play logic +*** TODO org-config — org-mode utility functions +*** TODO org-export-config — export helper functions +*** TODO local-repository — package snapshot logic +*** TODO show-kill-ring — kill ring display logic +*** TODO system-commands — reboot/logout/system action functions +*** TODO config-utilities — debug helper functions + +*Modules with partial coverage (expand existing tests):* + +*** TODO org-agenda-config — caching/TTL logic untested (only build-list covered) +*** TODO org-contacts-config — expand beyond capture/parse +*** TODO prog-shell — expand beyond make-script-executable +*** TODO ui-config — expand beyond buffer-status/cursor-color +*** TODO org-refile-config — expand beyond build-targets +*** TODO org-webclipper — expand beyond process (only 1 test file) +*** TODO org-noter-config — expand beyond generate-notes/title-to-slug +*** TODO browser-config — expand beyond current single test file +*** TODO flycheck-config — expand beyond languagetool setup +*** TODO org-drill-config — expand beyond first-function/font-switching + +*Lower priority (testable but less critical):* + +*** TODO chrono-tools — timer/clock functions +*** TODO help-utils — search dispatch (arch-wiki, devdoc, tldr, wikipedia) +*** TODO dirvish-config — wallpaper setter, custom functions +*** TODO dwim-shell-config — shell command definitions +*** TODO elfeed-config — podcast/feed helper functions +*** TODO eww-config — browser helper functions + ** TODO [#B] Review all config and pull library functions into system-lib file Extract reusable utility functions scattered across modules into system-lib.el @@ -1049,6 +1087,7 @@ CLOSED: [2025-11-03 Sun] * Method 3: Make *Fixing* Emacs Frictionless [0/9] ** DONE [#A] Write tests for cj/make-script-executable (suspected broken) +CLOSED: [2025-11-11 Tue] The `cj/make-script-executable` function automatically makes shell scripts executable when they have a shebang, but has no test coverage. **Suspected to be broken/not working.** @@ -1090,6 +1129,7 @@ Priority [#B] because if broken, it's a daily workflow issue (scripts don't auto Moved from inbox 2025-11-11. ** DONE [#A] Fix difftastic integration - not showing semantic diffs (just unified diff) +CLOSED: [2025-11-11 Tue] Difftastic was marked as "integrated" but diffs in magit and custom-buffer-file.el look identical to standard unified diffs. **Likely not actually using difftastic** - probably @@ -1180,6 +1220,7 @@ Priority [#B] because affects daily workflow when working on multiple elisp proj Moved from inbox 2025-11-11. ** DONE [#B] Remove ANSI color codes from Makefile - breaks test output readability +CLOSED: [2025-11-11 Tue] ANSI color codes render incorrectly in some terminals, making test output unreadable. Can see it's colored red, but can't read what the actual error message says due to @@ -1417,7 +1458,7 @@ CLOSED: [2025-11-12 Wed 02:41] Complete code already exists in someday-maybe.org. Need today and recurring. -** TODO [#B] Implement org-reveal presentation workflow +** TODO [#A] Implement org-reveal presentation workflow Create reveal.js slides from org-mode. |
