aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-26 00:31:44 -0500
committerCraig Jennings <c@cjennings.net>2026-05-26 00:31:44 -0500
commit8c42615b09471db089d81b7d702c60917fb85c79 (patch)
tree34b847a3e92658ad5c6d1cc6f566ff6622c14495 /tests
parent5ee0bdcc4a1bd4b935973be7201b20380ec8cb80 (diff)
downloadpearl-8c42615b09471db089d81b7d702c60917fb85c79.tar.gz
pearl-8c42615b09471db089d81b7d702c60917fb85c79.zip
feat: resolve a cycled TODO keyword to its Linear state at save
Cycling a ticket's TODO keyword (C-c C-t) now counts as a state edit. The dirty scan gains a keyword-cycle arm: state is dirty when the explicit id moved off its baseline (the picker arm, as before) or when the keyword diverged from the synced state name's slug. At save, the picker arm pushes the explicit id; the keyword-cycle arm resolves the keyword to a team state id by slug match, first by position, and pushes that. A keyword no team state matches is reported skipped rather than guessed. set-heading-state now slugifies the keyword instead of the static-map lookup, so the picker writes the same keyword the renderer derives. Without that, a picker edit to a non-standard state would read as permanently cycled. That retires pearl--map-linear-state-to-org, whose only caller this was, and its tests.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-pearl-mapping.el28
-rw-r--r--tests/test-pearl-save.el75
-rw-r--r--tests/test-pearl-todo-keywords.el22
3 files changed, 104 insertions, 21 deletions
diff --git a/tests/test-pearl-mapping.el b/tests/test-pearl-mapping.el
index 2a96163..b5faa3d 100644
--- a/tests/test-pearl-mapping.el
+++ b/tests/test-pearl-mapping.el
@@ -37,27 +37,13 @@
(pearl--todo-states-pattern-source nil))
,@body))
-;;; pearl--map-linear-state-to-org
-
-(ert-deftest test-pearl-map-linear-state-to-org-mapped-returns-keyword ()
- "A Linear state present in the mapping returns its org keyword."
- (test-pearl--with-mapping '(("Todo" . "TODO") ("In Progress" . "DOING"))
- (should (string-equal "DOING" (pearl--map-linear-state-to-org "In Progress")))))
-
-(ert-deftest test-pearl-map-linear-state-to-org-unmapped-defaults-todo ()
- "An unmapped Linear state falls back to TODO."
- (test-pearl--with-mapping '(("Todo" . "TODO") ("Done" . "DONE"))
- (should (string-equal "TODO" (pearl--map-linear-state-to-org "Triage")))))
-
-(ert-deftest test-pearl-map-linear-state-to-org-nil-defaults-todo ()
- "A nil state falls back to TODO rather than erroring."
- (test-pearl--with-mapping '(("Todo" . "TODO"))
- (should (string-equal "TODO" (pearl--map-linear-state-to-org nil)))))
-
-;; pearl--map-org-state-to-linear (the keyword->state reverse map) was removed
-;; with the org-sync push path in save-model-v2; the keyword-cycle save will
-;; resolve through the team's states (the keyword-derivation task), not a flat
-;; reverse mapping.
+;; pearl--map-linear-state-to-org (the static name->keyword lookup) was removed
+;; in the keyword-cycle save: the keyword is now always the slug of the state
+;; name (`pearl--state-name-to-keyword'), matching the workspace-derived #+TODO
+;; header, and the keyword->state-id direction resolves through the team's
+;; states (`pearl--resolve-keyword-to-state'), not a flat mapping. The mapping
+;; alist itself survives only as the source for `pearl--get-todo-states-pattern'
+;; (tested below).
;;; pearl--get-todo-states-pattern
diff --git a/tests/test-pearl-save.el b/tests/test-pearl-save.el
index 1f4f273..22df84e 100644
--- a/tests/test-pearl-save.el
+++ b/tests/test-pearl-save.el
@@ -153,6 +153,21 @@
(org-entry-put nil "LINEAR-STATE-ID" "s2")
(should (plist-get (pearl--issue-dirty-fields) :state))))
+(ert-deftest test-pearl-dirty-fields-state-keyword-cycle ()
+ "Cycling the TODO keyword away from the synced state-name slug marks state dirty,
+even when the explicit state id still matches its baseline."
+ (test-pearl-save--in-rendered (test-pearl-save--structured-issue)
+ (org-back-to-heading t)
+ (let ((org-after-todo-state-change-hook nil))
+ (org-todo "DONE")) ; TODO -> DONE, id untouched
+ (should (plist-get (pearl--issue-dirty-fields) :state))))
+
+(ert-deftest test-pearl-dirty-fields-state-fresh-keyword-not-dirty ()
+ "A freshly rendered state (keyword equals the state-name slug) is not state dirty."
+ (test-pearl-save--in-rendered (test-pearl-save--structured-issue)
+ (org-back-to-heading t)
+ (should-not (plist-get (pearl--issue-dirty-fields) :state))))
+
(ert-deftest test-pearl-dirty-fields-assignee-change ()
"An assignee id different from the synced baseline marks assignee dirty."
(test-pearl-save--in-rendered (test-pearl-save--structured-issue)
@@ -495,6 +510,66 @@ skipped (the remote push already landed), not signal."
(should (string= "s2" pushed))
(should (string= "s2" (org-entry-get marker "LINEAR-STATE-ID-SYNCED")))))))
+(ert-deftest test-pearl-save-state-keyword-cycle-resolves-and-pushes ()
+ "A keyword-cycled state (id unmoved) resolves the keyword to its team state id
+by slug match, pushes that id, and writes the resolved name/id/baseline."
+ (test-pearl-save--in-rendered
+ '(:id "u" :identifier "ENG-1" :title "t" :priority 2
+ :state (:id "s1" :name "Todo") :description "body")
+ (goto-char (point-min))
+ (insert "#+TODO: TODO IN-PROGRESS | DONE\n")
+ (org-mode) ; re-init so the keywords take
+ (goto-char (point-min))
+ (re-search-forward "ENG-1")
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-TEAM-ID" "team-1")
+ (progn
+ (let ((org-after-todo-state-change-hook nil))
+ (org-todo "IN-PROGRESS")) ; cycle keyword, leave id = s1
+ (let ((marker (point-marker)) outcome pushed)
+ (cl-letf (((symbol-function 'pearl--team-states)
+ (lambda (_tid)
+ '(((id . "s1") (name . "Todo") (type . "unstarted") (position . 1.0))
+ ((id . "s2") (name . "In Progress") (type . "started") (position . 2.0)))))
+ ((symbol-function 'pearl--fetch-issue-async)
+ (lambda (_id cb) (funcall cb '((state (id . "s1") (name . "Todo"))))))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (_id input cb)
+ (setq pushed (cdr (assoc "stateId" input)))
+ (funcall cb '(:success t)))))
+ (pearl--save-state-field marker (lambda (o) (setq outcome o)))
+ (should (eq 'pushed (plist-get outcome :status)))
+ (should (string= "s2" pushed))
+ (should (string= "s2" (org-entry-get marker "LINEAR-STATE-ID")))
+ (should (string= "In Progress" (org-entry-get marker "LINEAR-STATE-NAME")))
+ (should (string= "s2" (org-entry-get marker "LINEAR-STATE-ID-SYNCED"))))))))
+
+(ert-deftest test-pearl-save-state-keyword-cycle-unknown-keyword-skips ()
+ "A keyword no team state slugifies to cannot be resolved, so the save is skipped
+with no push."
+ (test-pearl-save--in-rendered
+ '(:id "u" :identifier "ENG-1" :title "t" :priority 2
+ :state (:id "s1" :name "Todo") :description "body")
+ (goto-char (point-min))
+ (insert "#+TODO: TODO BLOCKED | DONE\n")
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "ENG-1")
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-TEAM-ID" "team-1")
+ (progn
+ (let ((org-after-todo-state-change-hook nil))
+ (org-todo "BLOCKED")) ; no team state slugifies to BLOCKED
+ (let ((marker (point-marker)) outcome (pushed :none))
+ (cl-letf (((symbol-function 'pearl--team-states)
+ (lambda (_tid)
+ '(((id . "s1") (name . "Todo") (type . "unstarted") (position . 1.0)))))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (_id _input cb) (setq pushed :called) (funcall cb '(:success t)))))
+ (pearl--save-state-field marker (lambda (o) (setq outcome o)))
+ (should (eq 'skipped (plist-get outcome :status)))
+ (should (eq :none pushed)))))))
+
(ert-deftest test-pearl-save-labels-clean-push-advances-baseline ()
"A changed label set with an unmoved remote pushes labelIds and advances the baseline."
(test-pearl-save--in-rendered
diff --git a/tests/test-pearl-todo-keywords.el b/tests/test-pearl-todo-keywords.el
index 281beea..0555a4e 100644
--- a/tests/test-pearl-todo-keywords.el
+++ b/tests/test-pearl-todo-keywords.el
@@ -150,5 +150,27 @@
;; SHIPPED is an org done-keyword, so it lands on the done side
(should (re-search-forward "^#\\+TODO: *\\| SHIPPED$" nil t)))))
+;;; --resolve-keyword-to-state (keyword -> team state id, save-time)
+
+(ert-deftest test-pearl-resolve-keyword-to-state-collision-first-by-position ()
+ "When two state names slugify to the same keyword, the lower position wins."
+ (cl-letf (((symbol-function 'pearl--team-states)
+ (lambda (_tid)
+ '(((id . "s-hi") (name . "Dev Review") (type . "started") (position . 5.0))
+ ((id . "s-lo") (name . "Dev-Review") (type . "started") (position . 2.0))))))
+ (let ((r (pearl--resolve-keyword-to-state "DEV-REVIEW" "t1")))
+ (should (string= "s-lo" (plist-get r :id)))
+ (should (string= "Dev-Review" (plist-get r :name)))
+ (should (string= "started" (plist-get r :type))))))
+
+(ert-deftest test-pearl-resolve-keyword-to-state-no-match-or-empty-returns-nil ()
+ "A keyword no state slugifies to yields nil, as does an empty/failed fetch."
+ (cl-letf (((symbol-function 'pearl--team-states)
+ (lambda (_tid)
+ '(((id . "s1") (name . "Todo") (type . "unstarted") (position . 1.0))))))
+ (should-not (pearl--resolve-keyword-to-state "BLOCKED" "t1")))
+ (cl-letf (((symbol-function 'pearl--team-states) (lambda (_tid) nil)))
+ (should-not (pearl--resolve-keyword-to-state "TODO" "t1"))))
+
(provide 'test-pearl-todo-keywords)
;;; test-pearl-todo-keywords.el ends here