summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-02-14 00:45:55 -0600
committerCraig Jennings <c@cjennings.net>2026-02-14 00:45:55 -0600
commitebd8b2ce83941386b196e663d8f8ba83d7ce44c1 (patch)
treedba9b2da35a60c44f039495fbab565a0500c4373 /tests
parent78c3ef3c2008f72f9e46f30447c68d627bd693cd (diff)
test: add ERT coverage for modeline-config and hugo-config
Add 67 tests across 6 new test files for modeline and hugo modules. Refactor hugo-config: extract post-file-path and post-template helpers from interactive new-post function for testability. Update todo.org with test audit (31 modules), priority adjustments, and task cleanup.
Diffstat (limited to 'tests')
-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
6 files changed, 762 insertions, 0 deletions
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