aboutsummaryrefslogtreecommitdiff
path: root/tests/test-pearl-org-parse.el
blob: 87f4a02f4cf14c01a670525ce803786d4bd9045d (plain)
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
;;; test-pearl-org-parse.el --- Tests for pearl org parsing -*- 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 org readers `pearl--extract-org-heading-properties' and
;; `pearl--process-heading-at-point'.  The reader uses org APIs over the
;; LINEAR-* property drawer, so it works from anywhere in the entry, at any
;; heading depth, and is unbothered by body text or nested sub-entries.  The one
;; network boundary reached during processing (`--update-issue-state-async') is
;; stubbed.

;;; Code:

(require 'test-bootstrap (expand-file-name "test-bootstrap.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))
  ;; Declare the Linear keywords so `org-get-todo-state' recognizes them, the
  ;; way the generated file's `#+TODO:' line does in real use.
  `(let ((pearl-state-to-todo-mapping
          '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE")))
         (pearl-todo-states-pattern nil)
         (pearl--todo-states-pattern-source nil)
         (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
     (with-temp-buffer
       (insert ,content)
       (org-mode)
       (goto-char (point-min))
       ,@body)))

;;; extract-org-heading-properties

(ert-deftest test-pearl-extract-heading-properties-full ()
  "A complete Linear entry yields the todo keyword, ids, and team id."
  (test-pearl--in-org
      "*** TODO My issue\n:PROPERTIES:\n:LINEAR-ID: abc-123\n:LINEAR-IDENTIFIER: ENG-5\n:LINEAR-TEAM-ID: team-9\n:END:\n"
    (re-search-forward "My issue")
    (let ((props (pearl--extract-org-heading-properties)))
      (should (string-equal "TODO" (plist-get props :todo-state)))
      (should (string-equal "abc-123" (plist-get props :issue-id)))
      (should (string-equal "ENG-5" (plist-get props :issue-identifier)))
      (should (string-equal "team-9" (plist-get props :team-id))))))

(ert-deftest test-pearl-extract-reads-from-inside-the-entry ()
  "The reader works with point in the body, not only on the heading line."
  (test-pearl--in-org
      "*** TODO My issue\n:PROPERTIES:\n:LINEAR-ID: abc\n:END:\nsome body text here\n"
    (goto-char (point-max))
    (should (string-equal "abc" (plist-get (pearl--extract-org-heading-properties) :issue-id)))))

(ert-deftest test-pearl-extract-team-id-is-read-not-looked-up ()
  "The team id comes straight from LINEAR-TEAM-ID, with no network call."
  (cl-letf (((symbol-function 'pearl--get-team-id-by-name)
             (lambda (&rest _) (error "should not be called"))))
    (test-pearl--in-org
        "*** TODO x\n:PROPERTIES:\n:LINEAR-ID: i\n:LINEAR-TEAM-ID: t-1\n:END:\n"
      (re-search-forward "x")
      (should (string-equal "t-1" (plist-get (pearl--extract-org-heading-properties) :team-id))))))

(ert-deftest test-pearl-extract-missing-id ()
  "A drawer with no LINEAR-ID yields a nil issue-id."
  (test-pearl--in-org
      "*** TODO x\n:PROPERTIES:\n:LINEAR-IDENTIFIER: ENG-9\n:END:\n"
    (re-search-forward "x")
    (let ((props (pearl--extract-org-heading-properties)))
      (should (string-equal "TODO" (plist-get props :todo-state)))
      (should (null (plist-get props :issue-id)))
      (should (string-equal "ENG-9" (plist-get props :issue-identifier))))))

(ert-deftest test-pearl-extract-deeper-heading-now-supported ()
  "A level-4 entry is read the same as level-3 (the reader is depth-agnostic)."
  (test-pearl--in-org
      "*** TODO parent\n**** TODO child\n:PROPERTIES:\n:LINEAR-ID: c\n:END:\n"
    (re-search-forward "child")
    (should (string-equal "c" (plist-get (pearl--extract-org-heading-properties) :issue-id)))))

(ert-deftest test-pearl-extract-off-heading-nil ()
  "Before the first heading, nothing is extracted."
  (test-pearl--in-org
      "preamble line\n* Top\n"
    (goto-char (point-min))
    (should (null (pearl--extract-org-heading-properties)))))

;;; process-heading-at-point

(ert-deftest test-pearl-process-heading-updates-when-complete ()
  "A complete entry triggers an async state update with the mapped state."
  (let ((captured nil))
    (cl-letf (((symbol-function 'pearl--update-issue-state-async)
               (lambda (id state team) (setq captured (list id state team)))))
      (test-pearl--in-org
          "*** IN-PROGRESS x\n:PROPERTIES:\n:LINEAR-ID: i-1\n:LINEAR-IDENTIFIER: ENG-2\n:LINEAR-TEAM-ID: t-1\n:END:\n"
        (re-search-forward "x")
        (pearl--process-heading-at-point)
        (should (equal '("i-1" "In Progress" "t-1") captured))))))

(ert-deftest test-pearl-process-heading-skips-without-team ()
  "An entry missing its team id makes no API call."
  (let ((called nil))
    (cl-letf (((symbol-function 'pearl--update-issue-state-async)
               (lambda (&rest _) (setq called t))))
      (test-pearl--in-org
          "*** TODO x\n:PROPERTIES:\n:LINEAR-ID: i-1\n:LINEAR-IDENTIFIER: ENG-2\n:END:\n"
        (re-search-forward "x")
        (pearl--process-heading-at-point)
        (should-not called)))))

;;; render -> parse round trip

(ert-deftest test-pearl-render-parse-round-trip ()
  "An issue rendered by build-org-content parses back through the reader.
Locks the render/parse contract: the LINEAR-* drawer the renderer writes is
exactly what the reader extracts, and the rendered keyword is recognized."
  (let ((pearl-state-to-todo-mapping '(("In Progress" . "IN-PROGRESS")))
        (pearl-todo-states-pattern nil)
        (pearl--todo-states-pattern-source nil)
        (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
    (let ((content (pearl--build-org-content
                    '((:id "i-1" :identifier "ENG-2" :title "round trip"
                       :priority 2 :state (:name "In Progress") :team (:id "t-1"))))))
      (with-temp-buffer
        (insert content)
        (org-mode)
        (goto-char (point-min))
        (re-search-forward "round trip")
        (let ((props (pearl--extract-org-heading-properties)))
          (should (string-equal "i-1" (plist-get props :issue-id)))
          (should (string-equal "ENG-2" (plist-get props :issue-identifier)))
          (should (string-equal "IN-PROGRESS" (plist-get props :todo-state)))
          (should (string-equal "t-1" (plist-get props :team-id))))))))

(provide 'test-pearl-org-parse)
;;; test-pearl-org-parse.el ends here