diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-24 13:44:34 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-24 13:44:34 -0500 |
| commit | b081d62276378b3168c92c06153fd59db0589535 (patch) | |
| tree | 9be7f7d22e0c9b4a73432fe744c09bb456c671a9 /tests/test-pearl-merge.el | |
| download | pearl-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.el | 252 |
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 |
