aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test-integration-acceptance.el200
-rw-r--r--tests/test-pearl-comments.el65
-rw-r--r--tests/test-pearl-compose.el153
-rw-r--r--tests/test-pearl-format.el17
-rw-r--r--tests/test-pearl-heading.el117
-rw-r--r--tests/test-pearl-list-comments.el143
-rw-r--r--tests/test-pearl-org-write.el4
-rw-r--r--tests/test-pearl-output.el11
-rw-r--r--tests/test-pearl-query.el12
-rw-r--r--tests/test-pearl-title-sync.el23
-rw-r--r--tests/test-pearl-views.el7
11 files changed, 717 insertions, 35 deletions
diff --git a/tests/test-integration-acceptance.el b/tests/test-integration-acceptance.el
new file mode 100644
index 0000000..f83c891
--- /dev/null
+++ b/tests/test-integration-acceptance.el
@@ -0,0 +1,200 @@
+;;; test-integration-acceptance.el --- End-to-end acceptance flow -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; End-to-end acceptance flow exercising the integration contract between the
+;; query implementation and the org-representation implementation. The only
+;; stub is the single GraphQL chokepoint (`pearl--graphql-request-async'): a
+;; dispatcher routes by operation and returns canned, json-read-shaped data, so
+;; every layer above the wire — filter compilation, result classification,
+;; normalization, sorting, rendering, source-header round-trip, in-place merge,
+;; the dirty-buffer guard, the conflict-gated description sync, comment append,
+;; and a field-setter command — runs for real against a temp active file.
+;;
+;; Components integrated (all real unless noted):
+;; - pearl-run-saved-query -> --build-issue-filter -> --query-issues-async
+;; - --query-issues-async -> --graphql-request-async (MOCKED at the wire)
+;; - --render-query-result -> --normalize-issue -> --sort-issues
+;; - --update-org-from-issues (real disk write + buffer surface, surface MOCKED)
+;; - --read-active-source / --build-org-content header round-trip
+;; - pearl-refresh-current-view -> --merge-query-result (in-place merge)
+;; - pearl-sync-current-issue -> --sync-decision -> --update-issue-description-async
+;; - pearl-add-comment -> --create-comment-async -> --append-comment-to-issue
+;; - pearl-set-priority -> --push-issue-field -> --update-issue-async
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defvar test-integration--ops nil
+ "Accumulator of GraphQL operations the dispatcher saw, newest first.")
+
+(defun test-integration--nodes ()
+ "Two raw issue nodes, the shape `--normalize-issue' reads."
+ (list
+ '((id . "u-eng-1") (identifier . "ENG-1") (title . "First issue")
+ (description . "Original body.") (priority . 2)
+ (state (name . "Todo") (type . "unstarted"))
+ (url . "https://linear.app/x/ENG-1"))
+ '((id . "u-eng-2") (identifier . "ENG-2") (title . "Second issue")
+ (description . "More text.") (priority . 1)
+ (state (name . "In Progress") (type . "started"))
+ (url . "https://linear.app/x/ENG-2"))))
+
+(defun test-integration--op (query)
+ "Classify QUERY into the operation it represents.
+`commentCreate' and `issueUpdate' are matched before the broader patterns
+because the description-update mutation contains both `IssueDescription' and
+`issueUpdate' in its text."
+ (cond ((string-match-p "commentCreate" query) 'comment-create)
+ ((string-match-p "issueUpdate" query) 'issue-update)
+ ((string-match-p "issues(filter:" query) 'list)
+ ((string-match-p "issue(id:" query) 'fetch-desc)
+ (t 'unknown)))
+
+(defun test-integration--response (query)
+ "Canned, json-read-shaped data for QUERY's operation."
+ (pcase (test-integration--op query)
+ ('list `((data (issues (nodes . ,(vconcat (test-integration--nodes)))
+ (pageInfo (hasNextPage . :json-false)
+ (endCursor . "c"))))))
+ ('fetch-desc '((data (issue (description . "Original body.")
+ (updatedAt . "2026-05-24T00:00:00.000Z")))))
+ ('comment-create '((data (commentCreate
+ (success . t)
+ (comment (id . "cnew") (body . "looks good")
+ (createdAt . "2026-05-24T15:00:00.000Z")
+ (user (name . "Craig")))))))
+ ('issue-update '((data (issueUpdate
+ (success . t)
+ (issue (id . "u-eng-1")
+ (updatedAt . "2026-05-24T16:00:00.000Z"))))))
+ (_ nil)))
+
+(defun test-integration--dispatch (query _variables success-fn _error-fn)
+ "Stand-in for `--graphql-request-async': record QUERY's op and reply canned."
+ (push (test-integration--op query) test-integration--ops)
+ (funcall success-fn (test-integration--response query)))
+
+(defmacro test-integration--with-env (&rest body)
+ "Run BODY against a temp active file with the GraphQL wire stubbed."
+ (declare (indent 0))
+ `(let* ((tmp (make-temp-file "pearl-itest" nil ".org"))
+ (pearl-org-file-path tmp)
+ (pearl-api-key "test-key")
+ (pearl-fold-after-update nil)
+ (pearl-saved-queries
+ '(("Open" :filter (:open t) :sort priority :order asc)
+ ("Bugs" :filter (:labels ("bug") :open t))))
+ (test-integration--ops nil))
+ (when (find-buffer-visiting tmp) (kill-buffer (find-buffer-visiting tmp)))
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ #'test-integration--dispatch)
+ ;; no windows in batch; keep the surface step a no-op
+ ((symbol-function 'pearl--surface-buffer) (lambda (b) b)))
+ (unwind-protect
+ (progn ,@body)
+ (let ((buf (find-buffer-visiting tmp)))
+ (when buf
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf)))
+ (ignore-errors (delete-file tmp))))))
+
+(ert-deftest test-integration-run-saved-query-renders-active-file ()
+ "A saved query fetches, renders to the active file with a source header, and surfaces a buffer."
+ (test-integration--with-env
+ (pearl-run-saved-query "Open")
+ (let ((buf (find-buffer-visiting pearl-org-file-path)))
+ (should (buffer-live-p buf))
+ (with-current-buffer buf
+ (goto-char (point-min))
+ (should (re-search-forward "^#\\+LINEAR-SOURCE: " nil t))
+ (goto-char (point-min))
+ (should (re-search-forward "^#\\+LINEAR-COUNT: 2$" nil t))
+ (goto-char (point-min))
+ (should (re-search-forward "First issue" nil t))
+ (goto-char (point-min))
+ (should (re-search-forward "Second issue" nil t))
+ ;; the serialized source reads back as the filter source
+ (should (eq 'filter (plist-get (pearl--read-active-source) :type)))))
+ ;; the fetch went through the real query chokepoint
+ (should (memq 'list test-integration--ops))))
+
+(ert-deftest test-integration-refresh-reruns-recorded-source ()
+ "Refresh reads the recorded source from the buffer and merges a re-fetch in place."
+ (test-integration--with-env
+ (pearl-run-saved-query "Open")
+ (with-current-buffer (find-buffer-visiting pearl-org-file-path)
+ (setq test-integration--ops nil)
+ (pearl-refresh-current-view)
+ ;; refresh issued a fresh list query for the same source
+ (should (memq 'list test-integration--ops))
+ ;; both issues survive the in-place merge
+ (goto-char (point-min))
+ (should (re-search-forward "First issue" nil t))
+ (goto-char (point-min))
+ (should (re-search-forward "Second issue" nil t)))))
+
+(ert-deftest test-integration-switch-source-protects-dirty-buffer ()
+ "Switching to a different saved query with unsaved edits does not overwrite them."
+ (test-integration--with-env
+ (pearl-run-saved-query "Open")
+ (with-current-buffer (find-buffer-visiting pearl-org-file-path)
+ (goto-char (point-max))
+ (insert "\nUNSAVED LOCAL EDIT\n")
+ (should (buffer-modified-p))
+ ;; the switch must defer rather than clobber the dirty buffer
+ (pearl-run-saved-query "Bugs")
+ (goto-char (point-min))
+ (should (re-search-forward "UNSAVED LOCAL EDIT" nil t)))))
+
+(ert-deftest test-integration-issue-commands-from-subtree ()
+ "From inside a rendered issue subtree: syncing an edited body pushes, adding a comment inserts it, and setting priority drives a field update — all through the real request path."
+ (test-integration--with-env
+ (pearl-run-saved-query "Open")
+ (with-current-buffer (find-buffer-visiting pearl-org-file-path)
+ ;; --- sync a description edit (remote unchanged -> clean push) ---
+ (goto-char (point-min))
+ (re-search-forward "First issue")
+ (re-search-forward "Original body\\.")
+ (insert " EDITED")
+ (setq test-integration--ops nil)
+ (pearl-sync-current-issue)
+ (should (memq 'fetch-desc test-integration--ops))
+ (should (memq 'issue-update test-integration--ops))
+ ;; --- add a comment under the same issue ---
+ (goto-char (point-min))
+ (re-search-forward "First issue")
+ (setq test-integration--ops nil)
+ (pearl-add-comment "looks good")
+ (should (memq 'comment-create test-integration--ops))
+ (goto-char (point-min))
+ (should (re-search-forward "^\\*\\*\\* Comments$" nil t))
+ (should (re-search-forward "looks good" nil t))
+ ;; --- set priority from inside the subtree ---
+ (goto-char (point-min))
+ (re-search-forward "First issue")
+ (setq test-integration--ops nil)
+ (pearl-set-priority "Urgent")
+ (should (memq 'issue-update test-integration--ops)))))
+
+(provide 'test-integration-acceptance)
+;;; test-integration-acceptance.el ends here
diff --git a/tests/test-pearl-comments.el b/tests/test-pearl-comments.el
index d335132..85d1c84 100644
--- a/tests/test-pearl-comments.el
+++ b/tests/test-pearl-comments.el
@@ -20,9 +20,9 @@
;;; Commentary:
;; Tests for the comment thread: rendering a normalized comment and the
-;; oldest-first Comments subtree, including comments in the issue render, the
-;; commentCreate helper (stubbed at the HTTP boundary), the in-place append
-;; under the Comments subtree (creating it when absent), and the
+;; Comments subtree (ordered per `pearl-comment-sort-order'), including comments
+;; in the issue render, the commentCreate helper (stubbed at the HTTP boundary),
+;; the in-place append under the Comments subtree (creating it when absent), and the
;; `pearl-add-comment' command.
;;; Code:
@@ -64,12 +64,22 @@
"No comments renders nothing (no empty Comments subtree)."
(should (string= "" (pearl--format-comments nil))))
-(ert-deftest test-pearl-format-comments-oldest-first ()
- "Comments render under a Comments heading, oldest first regardless of input order."
- (let ((out (pearl--format-comments
- '((:id "c2" :author "B" :created-at "2026-05-23T12:00:00.000Z" :body "second")
- (:id "c1" :author "A" :created-at "2026-05-23T09:00:00.000Z" :body "first")))))
+(defconst test-pearl--two-comments
+ '((:id "c1" :author "A" :created-at "2026-05-23T09:00:00.000Z" :body "first")
+ (:id "c2" :author "B" :created-at "2026-05-23T12:00:00.000Z" :body "second"))
+ "Two comments, c1 older than c2, in input order regardless of render order.")
+
+(ert-deftest test-pearl-format-comments-newest-first ()
+ "With newest-first order, the most recent comment renders on top."
+ (let* ((pearl-comment-sort-order 'newest-first)
+ (out (pearl--format-comments test-pearl--two-comments)))
(should (string-match-p "^\\*\\*\\* Comments$" out))
+ (should (< (string-match "second" out) (string-match "first" out)))))
+
+(ert-deftest test-pearl-format-comments-oldest-first ()
+ "With oldest-first order, comments render chronologically, oldest on top."
+ (let* ((pearl-comment-sort-order 'oldest-first)
+ (out (pearl--format-comments test-pearl--two-comments)))
(should (< (string-match "first" out) (string-match "second" out)))))
;;; comments in the issue render
@@ -121,19 +131,32 @@
(should (re-search-forward "^\\*\\*\\* Comments$" nil t))
(should (re-search-forward "first comment" nil t))))
-(ert-deftest test-pearl-append-comment-after-existing ()
- "A new comment appends after an existing one under the Comments subtree."
- (test-pearl--in-org
- "** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n*** Comments\n**** A — 2026-05-23T09:00:00.000Z\nfirst\n"
- (pearl--append-comment-to-issue
- '(:id "c2" :author "B" :created-at "2026-05-23T12:00:00.000Z" :body "second"))
- (goto-char (point-min))
- ;; only one Comments heading, and the new comment follows the first
- (should (re-search-forward "^\\*\\*\\* Comments$" nil t))
- (should-not (re-search-forward "^\\*\\*\\* Comments$" nil t))
- (goto-char (point-min))
- (should (< (progn (re-search-forward "first") (point))
- (progn (re-search-forward "second") (point))))))
+(ert-deftest test-pearl-append-comment-newest-first-inserts-at-top ()
+ "With newest-first order, a new comment lands above the existing ones."
+ (let ((pearl-comment-sort-order 'newest-first))
+ (test-pearl--in-org
+ "** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n*** Comments\n**** A — 2026-05-23T09:00:00.000Z\nfirst\n"
+ (pearl--append-comment-to-issue
+ '(:id "c2" :author "B" :created-at "2026-05-23T12:00:00.000Z" :body "second"))
+ (goto-char (point-min))
+ ;; still exactly one Comments heading
+ (should (re-search-forward "^\\*\\*\\* Comments$" nil t))
+ (should-not (re-search-forward "^\\*\\*\\* Comments$" nil t))
+ (goto-char (point-min))
+ ;; the new comment is above the existing one
+ (should (< (progn (re-search-forward "second") (point))
+ (progn (re-search-forward "first") (point)))))))
+
+(ert-deftest test-pearl-append-comment-oldest-first-appends-at-bottom ()
+ "With oldest-first order, a new comment appends after the existing ones."
+ (let ((pearl-comment-sort-order 'oldest-first))
+ (test-pearl--in-org
+ "** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n*** Comments\n**** A — 2026-05-23T09:00:00.000Z\nfirst\n"
+ (pearl--append-comment-to-issue
+ '(:id "c2" :author "B" :created-at "2026-05-23T12:00:00.000Z" :body "second"))
+ (goto-char (point-min))
+ (should (< (progn (re-search-forward "first") (point))
+ (progn (re-search-forward "second") (point)))))))
;;; pearl-add-comment
diff --git a/tests/test-pearl-compose.el b/tests/test-pearl-compose.el
new file mode 100644
index 0000000..bda75cb
--- /dev/null
+++ b/tests/test-pearl-compose.el
@@ -0,0 +1,153 @@
+;;; test-pearl-compose.el --- Tests for the compose buffer -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for the shared compose buffer (`pearl--compose-in-buffer' and its
+;; submit/abort/body helpers) and the two commands wired onto it:
+;; `pearl-add-comment' (interactive composer path) and
+;; `pearl-compose-current-description'. The body is extracted below a read-only
+;; header and converted Org->Markdown by the callers.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--with-compose (initial &rest body)
+ "Open a compose buffer holding INITIAL, run BODY in it, capturing the submit.
+Binds `captured' to the value the on-finish receives (or `:none'). Stubs
+`pop-to-buffer' so the helper does not touch windows in batch."
+ (declare (indent 1))
+ `(let ((captured :none))
+ (cl-letf (((symbol-function 'pop-to-buffer) (lambda (b &rest _) b)))
+ (let ((buf (pearl--compose-in-buffer
+ "test" "# header line\n" ,initial
+ (lambda (text) (setq captured text)))))
+ (with-current-buffer buf ,@body)
+ (when (buffer-live-p buf) (kill-buffer buf))))))
+
+;;; --compose-in-buffer setup
+
+(ert-deftest test-pearl-compose-sets-up-org-buffer ()
+ "The compose buffer is an Org buffer with the header and the initial body."
+ (test-pearl--with-compose "draft text"
+ (should (eq major-mode 'org-mode))
+ (should (markerp pearl--compose-body-start))
+ (should (string-match-p "# header line" (buffer-string)))
+ (should (string= "draft text" (pearl--compose-body)))))
+
+(ert-deftest test-pearl-compose-body-excludes-header ()
+ "The body is only the text below the read-only header."
+ (test-pearl--with-compose ""
+ (goto-char (point-max))
+ (insert "the new body")
+ (should (string= "the new body" (pearl--compose-body)))))
+
+(ert-deftest test-pearl-compose-header-is-read-only ()
+ "Editing inside the header is refused."
+ (test-pearl--with-compose "body"
+ (goto-char (+ (point-min) 3))
+ (should-error (insert "x") :type 'text-read-only)))
+
+;;; submit / abort
+
+(ert-deftest test-pearl-compose-submit-hands-body-to-callback ()
+ "C-c C-c hands the trimmed body to the callback and kills the buffer."
+ (let ((captured :none) buf)
+ (cl-letf (((symbol-function 'pop-to-buffer) (lambda (b &rest _) b)))
+ (setq buf (pearl--compose-in-buffer
+ "t" "# h\n" " hello "
+ (lambda (text) (setq captured text))))
+ (with-current-buffer buf (pearl--compose-submit))
+ (should (string= "hello" captured))
+ (should-not (buffer-live-p buf)))))
+
+(ert-deftest test-pearl-compose-abort-skips-callback ()
+ "C-c C-k kills the buffer without invoking the callback."
+ (let ((called nil) buf)
+ (cl-letf (((symbol-function 'pop-to-buffer) (lambda (b &rest _) b)))
+ (setq buf (pearl--compose-in-buffer
+ "t" "# h\n" "body" (lambda (_) (setq called t))))
+ (with-current-buffer buf (pearl--compose-abort))
+ (should-not called)
+ (should-not (buffer-live-p buf)))))
+
+;;; add-comment composer wiring
+
+(defmacro test-pearl--in-issue (&rest body)
+ "Run BODY in an org buffer with point inside a Linear issue subtree."
+ (declare (indent 0))
+ `(let ((pearl-state-to-todo-mapping '(("Todo" . "TODO"))))
+ (with-temp-buffer
+ (insert "** TODO [#B] ENG-1: Issue\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n")
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "Body\\.")
+ ,@body)))
+
+(ert-deftest test-pearl-add-comment-interactive-composes-and-converts ()
+ "An interactive add-comment composes Org and sends the Markdown conversion."
+ (test-pearl--in-issue
+ (let (sent)
+ (cl-letf (((symbol-function 'pearl--compose-in-buffer)
+ (lambda (_label _instr initial on-finish)
+ ;; the composer would start empty for a new comment
+ (should (string= "" initial))
+ (funcall on-finish "see *bold* and `code`")))
+ ((symbol-function 'pearl--create-comment-async)
+ (lambda (_id body _cb) (setq sent body))))
+ (pearl-add-comment) ; interactive form: body nil
+ ;; Org markup converted to Markdown before sending
+ (should (string= "see **bold** and `code`" sent))))))
+
+(ert-deftest test-pearl-add-comment-with-body-skips-composer ()
+ "A non-interactive add-comment with BODY sends it directly, no composer."
+ (test-pearl--in-issue
+ (let ((composed nil) sent)
+ (cl-letf (((symbol-function 'pearl--compose-in-buffer)
+ (lambda (&rest _) (setq composed t)))
+ ((symbol-function 'pearl--create-comment-async)
+ (lambda (_id body _cb) (setq sent body))))
+ (pearl-add-comment "literal body")
+ (should-not composed)
+ (should (string= "literal body" sent))))))
+
+;;; compose-current-description wiring
+
+(ert-deftest test-pearl-compose-description-seeds-body-and-syncs ()
+ "The description composer seeds the current body and, on submit, writes it back and syncs."
+ (test-pearl--in-issue
+ (let (seeded synced)
+ (cl-letf (((symbol-function 'pearl--compose-in-buffer)
+ (lambda (_label _instr initial on-finish)
+ (setq seeded initial)
+ (funcall on-finish "Edited description")))
+ ((symbol-function 'pearl-sync-current-issue)
+ (lambda () (setq synced t))))
+ (pearl-compose-current-description)
+ ;; seeded with the existing body
+ (should (string= "Body." seeded))
+ ;; the edited text was written into the issue body, then synced
+ (should synced)
+ (goto-char (point-min))
+ (should (re-search-forward "Edited description" nil t))))))
+
+(provide 'test-pearl-compose)
+;;; test-pearl-compose.el ends here
diff --git a/tests/test-pearl-format.el b/tests/test-pearl-format.el
index 7310413..1e9fa53 100644
--- a/tests/test-pearl-format.el
+++ b/tests/test-pearl-format.el
@@ -56,7 +56,7 @@
"A full issue renders the heading and the namespaced LINEAR-* drawer."
(test-pearl--with-default-mapping
(let ((out (pearl--format-issue-as-org-entry (test-pearl--norm-full))))
- (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] Fix the thing$" out))
+ (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing$" out))
(should (string-match-p "^:LINEAR-ID: +uuid-1$" out))
(should (string-match-p "^:LINEAR-IDENTIFIER: +ENG-42$" out))
(should (string-match-p "^:LINEAR-STATE-NAME: +In Progress$" out))
@@ -80,7 +80,7 @@
"Null/missing optional fields render as empty values, and the body is empty."
(test-pearl--with-default-mapping
(let ((out (pearl--format-issue-as-org-entry (test-pearl--norm-bare))))
- (should (string-match-p "^\\*\\* TODO \\[#C\\] Bare issue$" out))
+ (should (string-match-p "^\\*\\* TODO \\[#C\\] ENG-7: Bare Issue$" out))
(should (string-match-p "^:LINEAR-PROJECT-NAME: +$" out))
(should (string-match-p "^:LINEAR-ASSIGNEE-NAME: +$" out))
(should (string-match-p "^:LINEAR-LABELS: +\\[\\]$" out))
@@ -93,11 +93,12 @@
(let ((out (pearl--format-issue-as-org-entry
'(:id "u" :identifier "ENG-1" :title "Fix [URGENT] bug"
:priority 1 :state (:name "Todo")))))
- (should (string-match-p "^\\*\\* TODO \\[#A\\] Fix URGENT bug$" out))
- ;; the title provenance hash is of the stripped (rendered) title, so a
- ;; later no-op title sync matches the heading and never clobbers brackets
+ (should (string-match-p "^\\*\\* TODO \\[#A\\] ENG-1: Fix URGENT Bug$" out))
+ ;; the title provenance hash is of the displayed (stripped + cased) title
+ ;; without the identifier prefix, so a later no-op title sync matches the
+ ;; heading and never clobbers brackets or pushes the prefix
(should (string-match-p
- (format "^:LINEAR-TITLE-SHA256: +%s$" (secure-hash 'sha256 "Fix URGENT bug"))
+ (format "^:LINEAR-TITLE-SHA256: +%s$" (secure-hash 'sha256 "Fix URGENT Bug"))
out)))))
;;; build-org-content
@@ -147,8 +148,8 @@ sort together (org-sort on the parent) instead of being orphan headings."
(test-pearl--with-default-mapping
(let ((out (pearl--build-org-content
(list (test-pearl--norm-full) (test-pearl--norm-bare)))))
- (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] Fix the thing$" out))
- (should (string-match-p "^\\*\\* TODO \\[#C\\] Bare issue$" out)))))
+ (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing$" out))
+ (should (string-match-p "^\\*\\* TODO \\[#C\\] ENG-7: Bare Issue$" out)))))
;;; --restore-page-visibility
diff --git a/tests/test-pearl-heading.el b/tests/test-pearl-heading.el
new file mode 100644
index 0000000..2675518
--- /dev/null
+++ b/tests/test-pearl-heading.el
@@ -0,0 +1,117 @@
+;;; test-pearl-heading.el --- Tests for heading title rendering -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for the heading title transforms: smart title case
+;; (`pearl--title-case'), the identifier prefix (`pearl--heading-with-identifier'
+;; / `pearl--strip-identifier-prefix'), and the way `--format-issue-as-org-entry'
+;; renders them while keeping `LINEAR-TITLE-SHA256' over the bare displayed
+;; title so an unedited heading is a no-op on title sync.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; --title-case
+
+(ert-deftest test-pearl-title-case-capitalizes-significant-words ()
+ "Significant words are capitalized; minor words mid-title stay lowercase."
+ (should (string= "Fix the Refresh Bug" (pearl--title-case "fix the refresh bug")))
+ (should (string= "A Tale of Two Cities" (pearl--title-case "a tale of two cities"))))
+
+(ert-deftest test-pearl-title-case-edges-capitalize-minor-words ()
+ "A minor word that is first or last is still capitalized."
+ (should (string= "Of Mice and Men" (pearl--title-case "of mice and men")))
+ (should (string= "What Is It For" (pearl--title-case "what is it for"))))
+
+(ert-deftest test-pearl-title-case-preserves-existing-uppercase ()
+ "A word that already has an uppercase letter (acronym, identifier) is left as-is."
+ (should (string= "API Rate Limits" (pearl--title-case "API rate limits")))
+ (should (string= "GraphQL and You" (pearl--title-case "GraphQL and you"))))
+
+(ert-deftest test-pearl-title-case-boundaries ()
+ "Empty, single-word, and extra-whitespace inputs behave."
+ (should (string= "" (pearl--title-case "")))
+ (should (string= "Bug" (pearl--title-case "bug")))
+ (should (string= "Fix the Bug" (pearl--title-case "fix the bug"))))
+
+(ert-deftest test-pearl-title-case-leaves-inner-punctuation-alone ()
+ "Only the first letter is upcased, so an apostrophe or hyphen mid-word is intact."
+ (should (string= "Don't Panic" (pearl--title-case "don't panic")))
+ (should (string= "Re-run the Task" (pearl--title-case "re-run the task"))))
+
+;;; --heading-with-identifier / --strip-identifier-prefix
+
+(ert-deftest test-pearl-heading-identifier-prefix-roundtrips ()
+ "Adding then stripping the identifier prefix is the identity."
+ (let ((pearl-show-identifier-in-heading t))
+ (let ((h (pearl--heading-with-identifier "Fix the Bug" "SE-401")))
+ (should (string= "SE-401: Fix the Bug" h))
+ (should (string= "Fix the Bug" (pearl--strip-identifier-prefix h "SE-401"))))))
+
+(ert-deftest test-pearl-heading-identifier-prefix-disabled ()
+ "With prefixing off, the title is returned unchanged."
+ (let ((pearl-show-identifier-in-heading nil))
+ (should (string= "Fix the Bug"
+ (pearl--heading-with-identifier "Fix the Bug" "SE-401")))))
+
+(ert-deftest test-pearl-strip-identifier-prefix-empty-or-absent ()
+ "Stripping is a no-op when the identifier is empty or not at the front."
+ (should (string= "Fix the Bug" (pearl--strip-identifier-prefix "Fix the Bug" "")))
+ (should (string= "Fix the Bug" (pearl--strip-identifier-prefix "Fix the Bug" nil)))
+ ;; an identifier that only appears mid-title is not stripped
+ (should (string= "see SE-9: later"
+ (pearl--strip-identifier-prefix "see SE-9: later" "SE-9"))))
+
+;;; rendering on / off
+
+(ert-deftest test-pearl-format-heading-defaults-prefix-and-title-case ()
+ "By default the heading carries the identifier prefix and a title-cased title."
+ (let ((pearl-show-identifier-in-heading t)
+ (pearl-title-case-headings t))
+ (let ((out (pearl--format-issue-as-org-entry
+ '(:id "u" :identifier "SE-401" :title "fix the refresh bug"
+ :priority 2 :state (:name "Todo")))))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] SE-401: Fix the Refresh Bug$" out)))))
+
+(ert-deftest test-pearl-format-heading-both-toggles-off-renders-verbatim ()
+ "With both toggles off, the heading is the bracket-stripped raw title, no prefix."
+ (let ((pearl-show-identifier-in-heading nil)
+ (pearl-title-case-headings nil))
+ (let ((out (pearl--format-issue-as-org-entry
+ '(:id "u" :identifier "SE-401" :title "fix the refresh bug"
+ :priority 2 :state (:name "Todo")))))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] fix the refresh bug$" out)))))
+
+(ert-deftest test-pearl-format-title-hash-is-over-displayed-title-no-prefix ()
+ "The title hash is over the displayed (cased) title without the identifier prefix.
+That is what makes a fetch + unedited heading a no-op on title sync."
+ (let ((pearl-show-identifier-in-heading t)
+ (pearl-title-case-headings t))
+ (let ((out (pearl--format-issue-as-org-entry
+ '(:id "u" :identifier "SE-401" :title "fix the refresh bug"
+ :priority 2 :state (:name "Todo")))))
+ (should (string-match-p
+ (format "^:LINEAR-TITLE-SHA256: +%s$"
+ (secure-hash 'sha256 "Fix the Refresh Bug"))
+ out)))))
+
+(provide 'test-pearl-heading)
+;;; test-pearl-heading.el ends here
diff --git a/tests/test-pearl-list-comments.el b/tests/test-pearl-list-comments.el
new file mode 100644
index 0000000..be0880e
--- /dev/null
+++ b/tests/test-pearl-list-comments.el
@@ -0,0 +1,143 @@
+;;; test-pearl-list-comments.el --- Tests for comments in the bulk list -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for rendering comments in the bulk issue list: the per-issue cap
+;; (`pearl--cap-issue-list-comments'), the `💬 shown/total' marker
+;; (`pearl--comment-count-marker', the Comments heading), and the way the
+;; append locator tolerates the marked heading so a later add-comment finds the
+;; existing subtree instead of creating a second one.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+(defun test-pearl--comments (n)
+ "Return N comment plists, newest-first (createdAt descending), as the fetch returns."
+ (let (out)
+ (dotimes (i n)
+ ;; i=0 is the newest; pad the index so string< orders correctly
+ (push (list :id (format "c%02d" (- n i))
+ :author "A"
+ :created-at (format "2026-05-%02dT00:00:00.000Z" (- n i))
+ :body (format "comment %d" (- n i)))
+ out))
+ (nreverse out)))
+
+;;; --cap-issue-list-comments
+
+(ert-deftest test-pearl-cap-comments-under-shown ()
+ "Fewer comments than the shown cap keeps them all with an exact total."
+ (let ((pearl-list-comments-shown 5) (pearl-list-comments-count-cap 25))
+ (let ((out (pearl--cap-issue-list-comments
+ (list :id "u" :comments (test-pearl--comments 3)))))
+ (should (= 3 (length (plist-get out :comments))))
+ (should (equal '(:shown 3 :total 3 :overflow nil)
+ (plist-get out :comment-count))))))
+
+(ert-deftest test-pearl-cap-comments-over-shown-under-cap ()
+ "More than the shown cap but within the count cap: show 5, total exact."
+ (let ((pearl-list-comments-shown 5) (pearl-list-comments-count-cap 25))
+ (let ((out (pearl--cap-issue-list-comments
+ (list :id "u" :comments (test-pearl--comments 18)))))
+ (should (= 5 (length (plist-get out :comments))))
+ (should (equal '(:shown 5 :total 18 :overflow nil)
+ (plist-get out :comment-count))))))
+
+(ert-deftest test-pearl-cap-comments-over-count-cap-overflows ()
+ "More than the count cap: total pins to the cap and overflows."
+ (let ((pearl-list-comments-shown 5) (pearl-list-comments-count-cap 25))
+ ;; the fetch pulls cap+1 = 26 to detect overflow
+ (let ((out (pearl--cap-issue-list-comments
+ (list :id "u" :comments (test-pearl--comments 26)))))
+ (should (= 5 (length (plist-get out :comments))))
+ (should (equal '(:shown 5 :total 25 :overflow t)
+ (plist-get out :comment-count))))))
+
+(ert-deftest test-pearl-cap-comments-none-unchanged ()
+ "An issue with no fetched comments is returned untouched, with no marker."
+ (let ((issue (list :id "u" :comments nil)))
+ (let ((out (pearl--cap-issue-list-comments issue)))
+ (should-not (plist-get out :comments))
+ (should-not (plist-get out :comment-count)))))
+
+(ert-deftest test-pearl-cap-comments-keeps-the-newest ()
+ "The cap keeps the newest `shown' comments (input arrives newest-first)."
+ (let ((pearl-list-comments-shown 3) (pearl-list-comments-count-cap 25))
+ (let* ((out (pearl--cap-issue-list-comments
+ (list :id "u" :comments (test-pearl--comments 7))))
+ (ids (mapcar (lambda (c) (plist-get c :id)) (plist-get out :comments))))
+ ;; newest three are c07, c06, c05 (the head of the newest-first list)
+ (should (equal '("c07" "c06" "c05") ids)))))
+
+;;; --comment-count-marker
+
+(ert-deftest test-pearl-comment-count-marker-forms ()
+ "The marker renders shown/total, with a `+' past the cap and nothing for nil."
+ (should (string= " 💬 5/18" (pearl--comment-count-marker '(:shown 5 :total 18 :overflow nil))))
+ (should (string= " 💬 5/25+" (pearl--comment-count-marker '(:shown 5 :total 25 :overflow t))))
+ (should (string= " 💬 3/3" (pearl--comment-count-marker '(:shown 3 :total 3 :overflow nil))))
+ (should (string= "" (pearl--comment-count-marker nil))))
+
+;;; --format-comments with a marker
+
+(ert-deftest test-pearl-format-comments-renders-marker-and-order ()
+ "With count-info the Comments heading carries the marker; bodies honor the sort order."
+ (let* ((pearl-comment-sort-order 'newest-first)
+ (comments (test-pearl--comments 5)) ; newest-first as fetched
+ (out (pearl--format-comments comments '(:shown 5 :total 12 :overflow nil))))
+ (should (string-match-p "^\\*\\*\\* Comments 💬 5/12$" out))
+ ;; newest (comment 5) renders before oldest (comment 1)
+ (should (< (string-match "comment 5\\b" out) (string-match "comment 1\\b" out)))))
+
+(ert-deftest test-pearl-format-comments-no-marker-without-count ()
+ "Without count-info (the single-issue thread), the heading has no marker."
+ (let ((out (pearl--format-comments (test-pearl--comments 2))))
+ (should (string-match-p "^\\*\\*\\* Comments$" out))))
+
+;;; the marked heading is still found by the append locator
+
+(ert-deftest test-pearl-append-finds-marked-comments-heading ()
+ "Adding a comment to an issue whose Comments heading carries a marker reuses it."
+ (let ((pearl-state-to-todo-mapping '(("Todo" . "TODO")))
+ (pearl-list-comments-shown 5) (pearl-list-comments-count-cap 25))
+ (let ((entry (pearl--format-issue-as-org-entry
+ (pearl--cap-issue-list-comments
+ (list :id "a" :identifier "ENG-1" :title "issue"
+ :priority 3 :state '(:name "Todo")
+ :comments (test-pearl--comments 8))))))
+ (with-temp-buffer
+ (insert entry)
+ (org-mode)
+ (goto-char (point-min))
+ ;; the rendered Comments heading carries the marker
+ (should (re-search-forward "^\\*\\*\\* Comments 💬 5/8$" nil t))
+ (goto-char (point-min))
+ (re-search-forward "issue")
+ (pearl--append-comment-to-issue
+ '(:id "cnew" :author "Z" :created-at "2026-06-01T00:00:00.000Z" :body "appended"))
+ ;; exactly one Comments heading, and the new comment landed under it
+ (goto-char (point-min))
+ (should (re-search-forward "^\\*\\*\\* Comments" nil t))
+ (should-not (re-search-forward "^\\*\\*\\* Comments" nil t))
+ (should (string-match-p "appended" (buffer-string)))))))
+
+(provide 'test-pearl-list-comments)
+;;; test-pearl-list-comments.el ends here
diff --git a/tests/test-pearl-org-write.el b/tests/test-pearl-org-write.el
index bd06ef0..6491b54 100644
--- a/tests/test-pearl-org-write.el
+++ b/tests/test-pearl-org-write.el
@@ -55,7 +55,7 @@ The state mapping is bound so rendering is deterministic."
(let ((content (with-temp-buffer (insert-file-contents tmp) (buffer-string))))
(should (string-match-p "#\\+title: Linear" content))
(should (string-match-p "#\\+LINEAR-SOURCE: " content))
- (should (string-match-p "\\*\\* TODO \\[#C\\] T" content)))))
+ (should (string-match-p "\\*\\* TODO \\[#C\\] ENG-1: T" content)))))
(ert-deftest test-pearl-update-org-clean-buffer-replaces-contents ()
"A clean visiting buffer is replaced in place and saved."
@@ -67,7 +67,7 @@ The state mapping is bound so rendering is deterministic."
(pearl--update-org-from-issues test-pearl--sample-issues)
(with-current-buffer buf
(should-not (buffer-modified-p))
- (should (string-match-p "\\*\\* TODO \\[#C\\] T" (buffer-string)))
+ (should (string-match-p "\\*\\* TODO \\[#C\\] ENG-1: T" (buffer-string)))
(should-not (string-match-p "old content" (buffer-string)))))))
(ert-deftest test-pearl-update-org-dirty-buffer-not-overwritten ()
diff --git a/tests/test-pearl-output.el b/tests/test-pearl-output.el
index f9f66eb..6eafbb4 100644
--- a/tests/test-pearl-output.el
+++ b/tests/test-pearl-output.el
@@ -68,6 +68,17 @@
(should (string-match-p "^#\\+title:" out))
(should-not (string-match-p "^\\*\\*\\* " out))))
+(ert-deftest test-pearl-build-org-content-title-cases-view-name ()
+ "The view name in the file title is title-cased when title-casing is on, raw when off."
+ (let ((case-fold-search nil) ; strict so casing actually matters
+ (source '(:type filter :name "my open bugs" :filter nil)))
+ (let ((pearl-title-case-headings t))
+ (should (string-match-p "^#\\+title: Linear — My Open Bugs$"
+ (pearl--build-org-content '() source))))
+ (let ((pearl-title-case-headings nil))
+ (should (string-match-p "^#\\+title: Linear — my open bugs$"
+ (pearl--build-org-content '() source))))))
+
;;; --read-active-source
(ert-deftest test-pearl-read-active-source-absent ()
diff --git a/tests/test-pearl-query.el b/tests/test-pearl-query.el
index b5d2816..697dd9a 100644
--- a/tests/test-pearl-query.el
+++ b/tests/test-pearl-query.el
@@ -144,8 +144,16 @@
;;; the bulk query fetches comments so the list can render them
(ert-deftest test-pearl-issues-query-requests-comments ()
- "The bulk issues query selects comments, so a populated list shows them."
- (should (string-match-p "comments[[:space:]]*{[[:space:]]*nodes" pearl--issues-query)))
+ "The bulk issues query selects capped, newest-first comments by default."
+ (let ((pearl-fetch-comments-in-list t)
+ (pearl-list-comments-count-cap 25))
+ (should (string-match-p "comments(first: 26, orderBy: createdAt)[[:space:]]*{[[:space:]]*nodes"
+ (pearl--issues-query)))))
+
+(ert-deftest test-pearl-issues-query-omits-comments-when-disabled ()
+ "With list comments disabled, the bulk query carries no comments selection."
+ (let ((pearl-fetch-comments-in-list nil))
+ (should-not (string-match-p "comments" (pearl--issues-query)))))
;;; malformed remote page shapes
diff --git a/tests/test-pearl-title-sync.el b/tests/test-pearl-title-sync.el
index 512794c..fc84a15 100644
--- a/tests/test-pearl-title-sync.el
+++ b/tests/test-pearl-title-sync.el
@@ -53,6 +53,29 @@
"*** TODO [#B] My issue title :tag:\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
(should (string= "My issue title" (pearl--issue-title-at-point)))))
+(ert-deftest test-pearl-issue-title-strips-identifier-prefix ()
+ "The extractor strips the rendered `IDENT: ' prefix using the drawer identifier."
+ (test-pearl--in-org
+ "*** TODO [#B] SE-401: Fix the Bug\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-IDENTIFIER: SE-401\n:END:\n"
+ (should (string= "Fix the Bug" (pearl--issue-title-at-point)))))
+
+(ert-deftest test-pearl-title-render-read-roundtrip-is-noop ()
+ "Rendering an issue then reading its heading back hashes to the stored title hash.
+This is the property that keeps a fetch + unedited heading from pushing the
+title-cased / identifier-prefixed display form to Linear."
+ (let ((pearl-show-identifier-in-heading t)
+ (pearl-title-case-headings t))
+ (test-pearl--in-org
+ (pearl--format-issue-as-org-entry
+ '(:id "a" :identifier "SE-401" :title "fix the refresh bug"
+ :priority 2 :state (:name "Todo")))
+ (goto-char (point-min))
+ (re-search-forward "SE-401")
+ (let ((stored (org-entry-get nil "LINEAR-TITLE-SHA256"))
+ (read-back (pearl--issue-title-at-point)))
+ (should (string= "Fix the Refresh Bug" read-back))
+ (should (string= stored (secure-hash 'sha256 read-back)))))))
+
;;; network helpers
(ert-deftest test-pearl-fetch-issue-title-parses-payload ()
diff --git a/tests/test-pearl-views.el b/tests/test-pearl-views.el
index 2a5d6bd..2f2adfd 100644
--- a/tests/test-pearl-views.el
+++ b/tests/test-pearl-views.el
@@ -123,8 +123,11 @@
;;; the view query fetches comments too
(ert-deftest test-pearl-view-issues-query-requests-comments ()
- "The Custom View query selects comments, so a view-populated list shows them."
- (should (string-match-p "comments[[:space:]]*{[[:space:]]*nodes" pearl--view-issues-query)))
+ "The Custom View query selects capped, newest-first comments by default."
+ (let ((pearl-fetch-comments-in-list t)
+ (pearl-list-comments-count-cap 25))
+ (should (string-match-p "comments(first: 26, orderBy: createdAt)[[:space:]]*{[[:space:]]*nodes"
+ (pearl--view-issues-query)))))
(provide 'test-pearl-views)
;;; test-pearl-views.el ends here