aboutsummaryrefslogtreecommitdiff
path: root/tests/test-pearl-org-parse.el
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test-pearl-org-parse.el')
-rw-r--r--tests/test-pearl-org-parse.el152
1 files changed, 152 insertions, 0 deletions
diff --git a/tests/test-pearl-org-parse.el b/tests/test-pearl-org-parse.el
new file mode 100644
index 0000000..87f4a02
--- /dev/null
+++ b/tests/test-pearl-org-parse.el
@@ -0,0 +1,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