1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
|
;;; test-pearl-title-sync.el --- Tests for title sync-back -*- 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 explicit title sync-back, a separate path from the description
;; sync that shares the `pearl--sync-decision' gate. Covers the title
;; extractor, the title fetch and update helpers (stubbed at the HTTP
;; boundary), the command's no-op / push / conflict branches, and the
;; deliberate bracket-stripping lossiness: a bracketed remote title renders
;; with its stored hash matching the stripped heading, so a no-op sync makes
;; no API call and never clobbers the brackets on Linear.
;;; Code:
(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
(require 'testutil-request (expand-file-name "testutil-request.el"))
(require 'cl-lib)
(defmacro test-pearl--in-org (content &rest body)
"Run BODY in an org-mode temp buffer holding CONTENT, default mapping bound."
(declare (indent 1))
`(let ((pearl-state-to-todo-mapping
'(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE")))
(org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
(with-temp-buffer
(insert ,content)
(org-mode)
(goto-char (point-min))
,@body)))
;;; --issue-title-at-point
(ert-deftest test-pearl-issue-title-strips-keyword-and-cookie ()
"The title extractor returns the heading text without TODO, priority, tags."
(test-pearl--in-org
"*** 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)))))
;;; network helpers
(ert-deftest test-pearl-fetch-issue-title-parses-payload ()
"The title fetch returns the remote title and timestamp."
(testutil-linear-with-response
'((data (issue (title . "Remote title") (updatedAt . "2026-05-23T00:00:00.000Z"))))
(let (result)
(pearl--fetch-issue-title-async "a" (lambda (r) (setq result r)))
(should (string= "Remote title" (plist-get result :title)))
(should (string= "2026-05-23T00:00:00.000Z" (plist-get result :updated-at))))))
(ert-deftest test-pearl-update-issue-title-success ()
"A successful title issueUpdate reports success."
(testutil-linear-with-response
'((data (issueUpdate (success . t)
(issue (id . "a") (updatedAt . "2026-05-23T01:00:00.000Z")))))
(let (result)
(pearl--update-issue-title-async "a" "New" (lambda (r) (setq result r)))
(should (eq t (plist-get result :success))))))
(ert-deftest test-pearl-update-issue-title-soft-fail ()
"A non-success title issueUpdate reports failure rather than erroring."
(testutil-linear-with-response
'((data (issueUpdate (success . :json-false) (issue . nil))))
(let ((called nil) result)
(pearl--update-issue-title-async "a" "New" (lambda (r) (setq called t result r)))
(should called)
(should-not (plist-get result :success)))))
;;; command branches
(defmacro test-pearl--with-title-stubs (remote-title update-spy &rest body)
"Run BODY with the title fetch/update helpers stubbed.
REMOTE-TITLE is the plist the fetch hands its callback. UPDATE-SPY collects
the titles pushed to the update helper, which reports success."
(declare (indent 2))
`(cl-letf (((symbol-function 'pearl--fetch-issue-title-async)
(lambda (_id cb) (funcall cb ,remote-title)))
((symbol-function 'pearl--update-issue-title-async)
(lambda (_id title cb)
(push title ,update-spy)
(funcall cb '(:success t :updated-at "2026-05-23T02:00:00.000Z")))))
,@body))
(ert-deftest test-pearl-sync-title-noop-skips-network ()
"No title edit: neither the fetch nor the update helper is called."
(let ((fetched nil) (updates nil))
(test-pearl--in-org
(format "*** TODO [#B] Same Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TITLE-SHA256: %s\n:END:\n"
(secure-hash 'sha256 "Same Title"))
(cl-letf (((symbol-function 'pearl--fetch-issue-title-async)
(lambda (&rest _) (setq fetched t)))
((symbol-function 'pearl--update-issue-title-async)
(lambda (&rest _) (push 'called updates))))
(pearl-sync-current-issue-title)
(should-not fetched)
(should-not updates)))))
(ert-deftest test-pearl-sync-title-push-advances-provenance ()
"An edited title against an unchanged remote pushes and advances the hash."
(let ((updates nil))
(test-pearl--in-org
(format "*** TODO [#B] Edited Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TITLE-SHA256: %s\n:END:\n"
(secure-hash 'sha256 "Old Title"))
(test-pearl--with-title-stubs '(:title "Old Title" :updated-at "t0") updates
(pearl-sync-current-issue-title)
(should (equal '("Edited Title") updates))
(should (string= (secure-hash 'sha256 "Edited Title")
(org-entry-get nil "LINEAR-TITLE-SHA256")))))))
(ert-deftest test-pearl-sync-title-conflict-refuses ()
"Title edited locally and changed on the remote too: refuse, do not push."
(let ((updates nil)
(stored (secure-hash 'sha256 "Old Title")))
(test-pearl--in-org
(format "*** TODO [#B] Edited Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TITLE-SHA256: %s\n:END:\n"
stored)
(test-pearl--with-title-stubs '(:title "Remote Changed Title" :updated-at "t1") updates
;; On conflict the command now prompts; cancel keeps the old behavior.
(cl-letf (((symbol-function 'pearl--read-conflict-resolution)
(lambda (_label) 'cancel)))
(pearl-sync-current-issue-title)
(should-not updates)
(should (string= stored (org-entry-get nil "LINEAR-TITLE-SHA256"))))))))
(ert-deftest test-pearl-sync-title-bracketed-remote-is-noop ()
"A bracketed remote title renders stripped; a no-op sync makes no API call.
This is the deliberate bracket-stripping lossiness: the stored hash is of the
stripped heading, so an unedited bracketed title is never clobbered on Linear."
(let ((fetched nil) (updates nil))
(test-pearl--in-org
;; remote title "Fix [URGENT] bug" renders to heading "Fix URGENT bug";
;; the stored hash is of the stripped form.
(format "*** TODO [#B] Fix URGENT bug\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TITLE-SHA256: %s\n:END:\n"
(secure-hash 'sha256 "Fix URGENT bug"))
(cl-letf (((symbol-function 'pearl--fetch-issue-title-async)
(lambda (&rest _) (setq fetched t)))
((symbol-function 'pearl--update-issue-title-async)
(lambda (&rest _) (push 'called updates))))
(pearl-sync-current-issue-title)
(should-not fetched)
(should-not updates)))))
(ert-deftest test-pearl-sync-title-not-on-issue-errors ()
"Running the command outside a Linear issue heading signals a user error."
(test-pearl--in-org "* Plain heading\nno id\n"
(should-error (pearl-sync-current-issue-title) :type 'user-error)))
(provide 'test-pearl-title-sync)
;;; test-pearl-title-sync.el ends here
|