;;; test-pearl-org-parse.el --- Tests for pearl org parsing -*- lexical-binding: t; -*- ;; Copyright (C) 2026 Craig Jennings ;; Author: Craig Jennings ;; 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 . ;;; 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