diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-integration-acceptance.el | 200 | ||||
| -rw-r--r-- | tests/test-pearl-comments.el | 65 | ||||
| -rw-r--r-- | tests/test-pearl-compose.el | 153 | ||||
| -rw-r--r-- | tests/test-pearl-format.el | 17 | ||||
| -rw-r--r-- | tests/test-pearl-heading.el | 117 | ||||
| -rw-r--r-- | tests/test-pearl-list-comments.el | 143 | ||||
| -rw-r--r-- | tests/test-pearl-org-write.el | 4 | ||||
| -rw-r--r-- | tests/test-pearl-output.el | 11 | ||||
| -rw-r--r-- | tests/test-pearl-query.el | 12 | ||||
| -rw-r--r-- | tests/test-pearl-title-sync.el | 23 | ||||
| -rw-r--r-- | tests/test-pearl-views.el | 7 |
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 |
