summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/hugo-config.el39
-rw-r--r--tests/test-hugo-config-open-blog-dir-external.el105
-rw-r--r--tests/test-hugo-config-post-file-path.el99
-rw-r--r--tests/test-hugo-config-post-template.el120
-rw-r--r--tests/test-hugo-config-toggle-draft.el161
-rw-r--r--tests/test-modeline-config-string-cut-middle.el145
-rw-r--r--tests/test-modeline-config-string-truncate-p.el132
-rw-r--r--todo.org77
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
diff --git a/todo.org b/todo.org
index 843781fd..b3ad44a3 100644
--- a/todo.org
+++ b/todo.org
@@ -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.