aboutsummaryrefslogtreecommitdiff
path: root/tests/test-pearl-merge.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-24 13:44:34 -0500
committerCraig Jennings <c@cjennings.net>2026-05-24 13:44:34 -0500
commitb081d62276378b3168c92c06153fd59db0589535 (patch)
tree9be7f7d22e0c9b4a73432fe744c09bb456c671a9 /tests/test-pearl-merge.el
downloadpearl-b081d62276378b3168c92c06153fd59db0589535.tar.gz
pearl-b081d62276378b3168c92c06153fd59db0589535.zip
feat: pearl — manage Linear issues from org-mode
Pearl fetches Linear issues into an org file and syncs edits back. It covers list / custom views / saved queries, per-issue and bulk rendering with comments inline, conflict-aware sync of descriptions, titles, and comments, field commands for priority / state / assignee / labels, and a transient dispatch menu. The render folds to a scannable outline and nests issues under a sortable parent. Based on and inspired by Gael Blanchemain's linear-emacs.
Diffstat (limited to 'tests/test-pearl-merge.el')
-rw-r--r--tests/test-pearl-merge.el252
1 files changed, 252 insertions, 0 deletions
diff --git a/tests/test-pearl-merge.el b/tests/test-pearl-merge.el
new file mode 100644
index 0000000..180539e
--- /dev/null
+++ b/tests/test-pearl-merge.el
@@ -0,0 +1,252 @@
+;;; test-pearl-merge.el --- Tests for merge-by-LINEAR-ID refresh -*- 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 same-source refresh merge: `pearl--merge-issues-into-buffer'
+;; updates existing issue subtrees in place by LINEAR-ID, appends new matches,
+;; drops issues gone from the result, and protects unpushed local edits (it
+;; neither overwrites nor drops a subtree whose body diverges from its stored
+;; provenance hash). Also covers the header refresh and the `--merge-query-result'
+;; render boundary that drives `pearl-refresh-current-view'.
+
+;;; 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-merge--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-merge--issue (id title desc)
+ "A normalized issue plist with ID, TITLE, and DESC for merge input."
+ (list :id id :identifier (concat "ENG-" id) :title title :description desc
+ :priority 2 :url (concat "https://linear.app/x/" id)
+ :updated-at "2026-05-23T03:00:00.000Z"
+ :state (list :id "s1" :name "Todo" :type "unstarted")
+ :team (list :id "t1" :key "ENG" :name "Engineering")))
+
+(defun test-pearl-merge--raw (id title desc)
+ "A raw issue node (json-read shape) with ID, TITLE, and DESC."
+ `((id . ,id) (identifier . ,(concat "ENG-" id)) (title . ,title)
+ (description . ,desc) (priority . 2) (url . ,(concat "https://linear.app/x/" id))
+ (updatedAt . "2026-05-23T03:00:00.000Z")
+ (state (id . "s1") (name . "Todo") (type . "unstarted"))
+ (team (id . "t1") (key . "ENG") (name . "Engineering"))
+ (labels (nodes . []))))
+
+(defun test-pearl-merge--buffer (&rest issues)
+ "A header plus the formatted ISSUES, as the active file would hold them."
+ (concat "#+title: Linear — My open issues\n"
+ "#+LINEAR-SOURCE: (:type filter :name \"My open issues\" :filter (:assignee :me))\n"
+ "#+LINEAR-RUN-AT: 2026-05-01 09:00\n"
+ "#+LINEAR-COUNT: 9\n"
+ "#+LINEAR-TRUNCATED: no\n\n"
+ (mapconcat #'pearl--format-issue-as-org-entry issues "")))
+
+;;; --merge-issues-into-buffer
+
+(ert-deftest test-pearl-merge-updates-existing-in-place ()
+ "An existing issue still in the result is re-rendered from the fetch in place."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha.")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta."))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "a" "Alpha Renamed" "Desc Alpha.")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta.")))))
+ (should (= 2 (plist-get counts :updated)))
+ (should (= 0 (plist-get counts :added)))
+ (should (= 0 (plist-get counts :dropped)))
+ (should (= 0 (plist-get counts :skipped)))
+ (goto-char (point-min))
+ (should (re-search-forward "Alpha Renamed" nil t))
+ (should-not (save-excursion (re-search-forward "^\\*\\*\\* .*Alpha$" nil t)))
+ ;; Alpha still precedes Beta — order is stable.
+ (goto-char (point-min))
+ (let ((a (progn (re-search-forward "Alpha Renamed") (point)))
+ (b (progn (re-search-forward "Beta") (point))))
+ (should (< a b))))))
+
+(ert-deftest test-pearl-merge-appends-new-issue ()
+ "An issue new to the result is appended after the existing ones."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha."))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "a" "Alpha" "Desc Alpha.")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta.")))))
+ (should (= 1 (plist-get counts :added)))
+ (goto-char (point-min))
+ (should (string= "a" (progn (re-search-forward "LINEAR-ID: *\\(.*\\)$") (match-string 1))))
+ (should (re-search-forward "LINEAR-ID: *b" nil t))
+ (goto-char (point-min))
+ (should (< (progn (re-search-forward "Alpha") (point))
+ (progn (re-search-forward "Beta") (point)))))))
+
+(ert-deftest test-pearl-merge-drops-absent-issue ()
+ "A clean issue no longer in the result is dropped."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha.")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta."))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "a" "Alpha" "Desc Alpha.")))))
+ (should (= 1 (plist-get counts :dropped)))
+ (goto-char (point-min))
+ (should (re-search-forward "Alpha" nil t))
+ (goto-char (point-min))
+ (should-not (re-search-forward "Beta" nil t)))))
+
+(ert-deftest test-pearl-merge-keeps-unpushed-edit-on-update ()
+ "An existing subtree with unpushed body edits is kept, not overwritten."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha."))
+ ;; Dirty the body so its hash no longer matches the stored provenance.
+ (goto-char (point-min))
+ (re-search-forward "Desc Alpha\\.")
+ (end-of-line)
+ (insert " UNPUSHED EDIT")
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "a" "Alpha Renamed" "Remote desc.")))))
+ (should (= 1 (plist-get counts :skipped)))
+ (should (= 0 (plist-get counts :updated)))
+ (goto-char (point-min))
+ ;; Local edit and old heading survive; the remote rename did not land.
+ (should (re-search-forward "UNPUSHED EDIT" nil t))
+ (goto-char (point-min))
+ (should-not (re-search-forward "Alpha Renamed" nil t)))))
+
+(ert-deftest test-pearl-merge-keeps-dirty-issue-absent-from-result ()
+ "A dirty issue gone from the result is kept rather than dropped (no data loss)."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha.")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta."))
+ (goto-char (point-min))
+ (re-search-forward "Desc Alpha\\.")
+ (end-of-line)
+ (insert " UNPUSHED EDIT")
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "b" "Beta" "Desc Beta.")))))
+ (should (= 1 (plist-get counts :skipped)))
+ (should (= 0 (plist-get counts :dropped)))
+ (goto-char (point-min))
+ (should (re-search-forward "UNPUSHED EDIT" nil t)))))
+
+(ert-deftest test-pearl-merge-updates-rich-description-issue-in-place ()
+ "An unedited issue with lossy markdown (a heading) is updated, not skipped.
+Regression: the dirty check round-tripped Org back to markdown and mistook a
+lossy round-trip (# heading -> bold, *italic* -> **bold**) for a local edit, so
+refresh silently skipped every rich-text issue."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer
+ (test-pearl-merge--issue "a" "Alpha" "# Heading\n\nSome body text."))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "a" "Alpha Renamed"
+ "# Heading\n\nSome body text.")))))
+ (should (= 1 (plist-get counts :updated)))
+ (should (= 0 (plist-get counts :skipped)))
+ (goto-char (point-min))
+ (should (re-search-forward "Alpha Renamed" nil t)))))
+
+(ert-deftest test-pearl-subtree-dirty-p-rich-description-unedited-not-dirty ()
+ "A freshly rendered subtree with lossy-markdown description is not dirty unedited."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer
+ (test-pearl-merge--issue "a" "Alpha" "# Heading\n\nSome body text."))
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (beginning-of-line)
+ (should-not (pearl--subtree-dirty-p))))
+
+(ert-deftest test-pearl-subtree-dirty-p-empty-description-not-dirty ()
+ "An issue with an empty description is not dirty.
+Regression: body extraction overshot an empty body into the next issue's
+subtree, so every description-less issue read as a local edit."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer
+ (test-pearl-merge--issue "a" "Alpha" "")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta."))
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (beginning-of-line)
+ (should (string= "" (pearl--issue-body-at-point)))
+ (should-not (pearl--subtree-dirty-p))))
+
+(ert-deftest test-pearl-subtree-dirty-p-edited-body-is-dirty ()
+ "Editing the rendered body still marks the subtree dirty (edit detection holds)."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer
+ (test-pearl-merge--issue "a" "Alpha" "# Heading\n\nSome body text."))
+ (goto-char (point-min))
+ (re-search-forward "Some body text\\.")
+ (end-of-line)
+ (insert " LOCAL EDIT")
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (beginning-of-line)
+ (should (pearl--subtree-dirty-p))))
+
+;;; --update-source-header
+
+(ert-deftest test-pearl-merge-update-source-header-rewrites-count ()
+ "The header refresh updates the count and truncation lines in place."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha."))
+ (pearl--update-source-header 5 t)
+ (goto-char (point-min))
+ (should (re-search-forward "^#\\+LINEAR-COUNT: 5$" nil t))
+ (goto-char (point-min))
+ (should (re-search-forward "^#\\+LINEAR-TRUNCATED: yes$" nil t))))
+
+;;; --merge-query-result (render boundary)
+
+(ert-deftest test-pearl-merge-query-result-merges-and-updates-header ()
+ "An ok result normalizes its raw nodes, merges them, and refreshes the count."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha."))
+ (let ((source '(:type filter :name "My open issues" :filter (:assignee :me)))
+ (result (pearl--make-query-result
+ 'ok :issues (list (test-pearl-merge--raw "a" "Alpha Renamed" "Desc Alpha.")
+ (test-pearl-merge--raw "c" "Gamma" "Desc Gamma.")))))
+ (pearl--merge-query-result result source)
+ (goto-char (point-min))
+ (should (re-search-forward "Alpha Renamed" nil t))
+ (should (re-search-forward "Gamma" nil t))
+ (goto-char (point-min))
+ (should (re-search-forward "^#\\+LINEAR-COUNT: 2$" nil t)))))
+
+(ert-deftest test-pearl-merge-query-result-empty-leaves-buffer ()
+ "An empty result leaves the buffer unchanged rather than dropping everything."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha."))
+ (let ((source '(:type filter :name "My open issues" :filter (:assignee :me)))
+ (before (buffer-string)))
+ (pearl--merge-query-result (pearl--make-query-result 'empty) source)
+ (should (string= before (buffer-string))))))
+
+(provide 'test-pearl-merge)
+;;; test-pearl-merge.el ends here