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
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
|
;;; test-pearl-conflict.el --- Tests for interactive conflict resolution -*- 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 conflict-resolution foundation: the no-data-loss stash
;; (`pearl--stash-conflict-text'), the smerge conflict-string builder
;; (`pearl--conflict-smerge-string'), and the resolution prompt
;; (`pearl--read-conflict-resolution', cancel-by-default).
;;; Code:
(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
(require 'cl-lib)
(defmacro test-pearl-conflict--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)))
(defun test-pearl-conflict--marker ()
"Return a marker at the first issue heading in the current buffer."
(goto-char (point-min))
(re-search-forward "^\\*\\*\\* ")
(beginning-of-line)
(point-marker))
;;; --stash-conflict-text
(ert-deftest test-pearl-stash-conflict-text-to-kill-ring-and-buffer ()
"Stashing puts the text on the kill ring and into the backup buffer."
(let ((kill-ring nil))
(when (get-buffer "*pearl-conflict-backup*")
(kill-buffer "*pearl-conflict-backup*"))
(pearl--stash-conflict-text "ENG-1 description" "My local edit.")
(should (string= "My local edit." (current-kill 0)))
(with-current-buffer "*pearl-conflict-backup*"
(let ((s (buffer-string)))
(should (string-match-p "ENG-1 description" s))
(should (string-match-p "My local edit\\." s))))))
(ert-deftest test-pearl-stash-conflict-text-appends-not-overwrites ()
"A second stash appends below the first, preserving earlier backups."
(let ((kill-ring nil))
(when (get-buffer "*pearl-conflict-backup*")
(kill-buffer "*pearl-conflict-backup*"))
(pearl--stash-conflict-text "ENG-1 description" "First edit.")
(pearl--stash-conflict-text "ENG-2 title" "Second edit.")
(with-current-buffer "*pearl-conflict-backup*"
(let ((s (buffer-string)))
(should (string-match-p "First edit\\." s))
(should (string-match-p "Second edit\\." s))))))
(ert-deftest test-pearl-stash-conflict-text-empty-is-noop ()
"Stashing empty text touches neither the kill ring nor the backup buffer."
(let ((kill-ring nil))
(when (get-buffer "*pearl-conflict-backup*")
(kill-buffer "*pearl-conflict-backup*"))
(pearl--stash-conflict-text "ENG-1 description" "")
(should (null kill-ring))
(should-not (get-buffer "*pearl-conflict-backup*"))))
;;; --conflict-smerge-string
(ert-deftest test-pearl-conflict-smerge-string-has-markers-in-order ()
"The smerge string carries the three markers with local before remote."
(let ((s (pearl--conflict-smerge-string "LOCAL TEXT" "REMOTE TEXT")))
(should (string-match-p "^<<<<<<<" s))
(should (string-match-p "^=======" s))
(should (string-match-p "^>>>>>>>" s))
(let ((lt (string-match "LOCAL TEXT" s))
(sep (string-match "^=======" s))
(rt (string-match "REMOTE TEXT" s)))
(should (< lt sep))
(should (< sep rt)))))
(ert-deftest test-pearl-conflict-smerge-string-newline-terminates-sections ()
"Sections whose text lacks a trailing newline still get one before a marker."
(let ((s (pearl--conflict-smerge-string "no-newline-local" "no-newline-remote")))
;; The separator and closing markers must each start their own line.
(should (string-match-p "no-newline-local\n=======" s))
(should (string-match-p "no-newline-remote\n>>>>>>>" s))))
;;; --read-conflict-resolution
(ert-deftest test-pearl-read-conflict-resolution-maps-choices ()
"Each prompt label maps to its resolution symbol."
(dolist (case '(("use local" . use-local)
("use remote" . use-remote)
("rewrite" . rewrite)
("cancel" . cancel)))
(cl-letf (((symbol-function 'completing-read)
(lambda (_prompt collection &rest _)
;; Return the first offered label containing the case keyword.
(seq-find (lambda (c) (string-match-p (car case) c)) collection))))
(should (eq (cdr case) (pearl--read-conflict-resolution "ENG-1 description"))))))
(ert-deftest test-pearl-read-conflict-resolution-defaults-to-cancel ()
"Selecting the default (a bare RET) resolves to `cancel'."
(cl-letf (((symbol-function 'completing-read)
(lambda (_prompt _collection &rest args)
;; Emulate RET-on-default: completing-read returns the DEF arg.
;; args = (predicate require-match initial-input hist def ...),
;; so DEF is the 5th element, index 4.
(nth 4 args))))
(should (eq 'cancel (pearl--read-conflict-resolution "ENG-1 description")))))
;;; --set-entry-body-at-point
(ert-deftest test-pearl-set-entry-body-replaces-body-keeps-drawer ()
"Setting the body replaces the text after the drawer and preserves the drawer."
(test-pearl-conflict--in-org
"*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: x\n:END:\nOld body.\n"
(re-search-forward "Title")
(pearl--set-entry-body-at-point "New body line.")
(goto-char (point-min))
(should (re-search-forward "New body line\\." nil t))
(goto-char (point-min))
(should-not (re-search-forward "Old body\\." nil t))
(should (string= "a" (org-entry-get nil "LINEAR-ID")))))
(ert-deftest test-pearl-set-entry-body-stops-before-child-heading ()
"Setting the body does not disturb a child Comments subtree."
(test-pearl-conflict--in-org
(concat "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nOld body.\n"
"**** Comments\n***** Me — t\nmine\n")
(re-search-forward "Title")
(pearl--set-entry-body-at-point "New body.")
(goto-char (point-min))
(should (re-search-forward "New body\\." nil t))
(should (re-search-forward "Comments" nil t))
(should (re-search-forward "mine" nil t))))
;;; --resolve-conflict
(ert-deftest test-pearl-resolve-conflict-cancel-does-nothing ()
"Cancel applies nothing, pushes nothing, and leaves the provenance hash."
(test-pearl-conflict--in-org
"*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: H0\n:END:\nbody\n"
(let ((applied nil) (pushed nil) (marker (test-pearl-conflict--marker)))
(cl-letf (((symbol-function 'pearl--read-conflict-resolution) (lambda (_) 'cancel)))
(pearl--resolve-conflict
"ENG-1 description" "local" "remote" marker "LINEAR-DESC-SHA256"
(lambda (_md) (setq applied t))
(lambda (_md cb) (setq pushed t) (funcall cb t)))
(should-not applied)
(should-not pushed)
(should (string= "H0" (org-entry-get marker "LINEAR-DESC-SHA256")))))))
(ert-deftest test-pearl-resolve-conflict-use-local-pushes-and-advances ()
"Use-local pushes the local text and advances the hash to it on success."
(test-pearl-conflict--in-org
"*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: H0\n:END:\nbody\n"
(let ((pushed-md nil) (applied nil) (marker (test-pearl-conflict--marker)))
(cl-letf (((symbol-function 'pearl--read-conflict-resolution) (lambda (_) 'use-local)))
(pearl--resolve-conflict
"ENG-1 description" "local text" "remote text" marker "LINEAR-DESC-SHA256"
(lambda (_md) (setq applied t))
(lambda (md cb) (setq pushed-md md) (funcall cb t)))
(should (string= "local text" pushed-md))
(should-not applied)
(should (string= (secure-hash 'sha256 "local text")
(org-entry-get marker "LINEAR-DESC-SHA256")))))))
(ert-deftest test-pearl-resolve-conflict-use-remote-stashes-applies-no-push ()
"Use-remote stashes local, writes remote, advances the hash, and never pushes."
(let ((kill-ring nil))
(test-pearl-conflict--in-org
"*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: H0\n:END:\nbody\n"
(let ((applied-md nil) (pushed nil) (marker (test-pearl-conflict--marker)))
(cl-letf (((symbol-function 'pearl--read-conflict-resolution) (lambda (_) 'use-remote)))
(pearl--resolve-conflict
"ENG-1 description" "local text" "remote text" marker "LINEAR-DESC-SHA256"
(lambda (md) (setq applied-md md))
(lambda (_md cb) (setq pushed t) (funcall cb t)))
(should (string= "remote text" applied-md))
(should-not pushed)
(should (string= (secure-hash 'sha256 "remote text")
(org-entry-get marker "LINEAR-DESC-SHA256")))
;; the local edit was stashed, not lost
(should (string= "local text" (current-kill 0))))))))
(ert-deftest test-pearl-resolve-conflict-rewrite-applies-and-pushes ()
"Rewrite stashes local, then on the smerge finish applies and pushes the merge."
(let ((kill-ring nil))
(test-pearl-conflict--in-org
"*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: H0\n:END:\nbody\n"
(let ((applied nil) (pushed nil) (marker (test-pearl-conflict--marker)))
(cl-letf (((symbol-function 'pearl--read-conflict-resolution) (lambda (_) 'rewrite))
;; Emulate the user resolving the buffer and committing.
((symbol-function 'pearl--resolve-conflict-in-smerge)
(lambda (_label _local _remote on-finish)
(funcall on-finish "merged text"))))
(pearl--resolve-conflict
"ENG-1 description" "local text" "remote text" marker "LINEAR-DESC-SHA256"
(lambda (md) (setq applied md))
(lambda (md cb) (setq pushed md) (funcall cb t)))
(should (string= "local text" (current-kill 0)))
(should (string= "merged text" applied))
(should (string= "merged text" pushed))
(should (string= (secure-hash 'sha256 "merged text")
(org-entry-get marker "LINEAR-DESC-SHA256"))))))))
;;; --conflict-has-markers-p
(ert-deftest test-pearl-conflict-has-markers-p ()
"Unresolved marker text reports markers; resolved text does not."
(should (pearl--conflict-has-markers-p
(pearl--conflict-smerge-string "mine" "theirs")))
(should-not (pearl--conflict-has-markers-p "just the merged line\n")))
;;; --resolve-conflict-in-smerge (buffer setup)
(ert-deftest test-pearl-resolve-conflict-in-smerge-sets-up-buffer ()
"Opening the smerge buffer fills it with both sides and arms the callback."
(let ((buf-name "*pearl-merge: ENG-1 description*"))
(when (get-buffer buf-name) (kill-buffer buf-name))
(cl-letf (((symbol-function 'pop-to-buffer) #'ignore))
(pearl--resolve-conflict-in-smerge
"ENG-1 description" "my local" "the remote" (lambda (_) nil)))
(let ((buf (get-buffer buf-name)))
(should buf)
(with-current-buffer buf
(should (string-match-p "my local" (buffer-string)))
(should (string-match-p "the remote" (buffer-string)))
(should (bound-and-true-p smerge-mode))
(should (functionp pearl--conflict-on-finish)))
(kill-buffer buf))))
;;; --conflict-commit / --conflict-abort
(ert-deftest test-pearl-conflict-commit-refuses-with-markers ()
"Committing with markers still present errors and never calls the callback."
(let ((called nil) (buf-name "*pearl-merge: ENG-1 description*"))
(when (get-buffer buf-name) (kill-buffer buf-name))
(cl-letf (((symbol-function 'pop-to-buffer) #'ignore))
(pearl--resolve-conflict-in-smerge
"ENG-1 description" "mine" "theirs" (lambda (_) (setq called t))))
(with-current-buffer buf-name
(should-error (pearl--conflict-commit) :type 'user-error))
(should-not called)
(when (get-buffer buf-name) (kill-buffer buf-name))))
(ert-deftest test-pearl-conflict-commit-resolved-calls-callback-and-kills ()
"With markers resolved, commit hands the text to the callback and kills the buffer."
(let ((got nil) (buf-name "*pearl-merge: ENG-1 description*"))
(when (get-buffer buf-name) (kill-buffer buf-name))
(cl-letf (((symbol-function 'pop-to-buffer) #'ignore))
(pearl--resolve-conflict-in-smerge
"ENG-1 description" "mine" "theirs" (lambda (txt) (setq got txt))))
(with-current-buffer buf-name
(erase-buffer)
(insert "the reconciled text\n")
(pearl--conflict-commit))
(should (string= "the reconciled text\n" got))
(should-not (get-buffer buf-name))))
(provide 'test-pearl-conflict)
;;; test-pearl-conflict.el ends here
|