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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
|
;;; 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)))))
(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 ()
"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)))
(ert-deftest test-pearl-sync-title-push-failure-keeps-provenance ()
"A failed title push keeps the hash and the edited heading for retry."
(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)
(cl-letf (((symbol-function 'pearl--fetch-issue-title-async)
(lambda (_id cb) (funcall cb '(:title "Old Title" :updated-at "t0"))))
((symbol-function 'pearl--update-issue-title-async)
(lambda (_id title cb) (push title updates) (funcall cb '(:success nil)))))
(pearl-sync-current-issue-title)
(should (equal '("Edited Title") updates))
(should (string= stored (org-entry-get nil "LINEAR-TITLE-SHA256")))
(should (string= "Edited Title" (pearl--issue-title-at-point)))))))
(provide 'test-pearl-title-sync)
;;; test-pearl-title-sync.el ends here
|