aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 20:20:00 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 20:20:00 -0500
commit09cf1f02161aaef9bb1ad26907472bea52f53a24 (patch)
tree9ad920b04eaa01d0e8bf0744fc4384a865956118 /tests
parent8fa0febbc3a3afe78d1a5abbbb944adf6dd3612a (diff)
downloadpearl-09cf1f02161aaef9bb1ad26907472bea52f53a24.tar.gz
pearl-09cf1f02161aaef9bb1ad26907472bea52f53a24.zip
feat(save): add the atomic field-save engine and the assignee saver
pearl--run-atomic-field-save is the structured-field sibling of the text engine pearl--run-field-save. It runs the atomic three-way over a spec's live and baseline values. A missing baseline skips with missing-property (a legacy file — refresh first). Live equal to baseline is unchanged. Otherwise it fetches the remote and compares: remote already equal to live converges and advances the baseline, remote unmoved from baseline is a clean push, and a genuine divergence runs the atomic conflict gate. It reuses the existing save-outcome contract, so structured fields report the same status/reason shape as title and description. pearl--save-assignee-field is the first saver on it — the cleanest relation field. Its live id is LINEAR-ASSIGNEE-ID diffed against LINEAR-ASSIGNEE-ID-SYNCED, the remote comes from the issue node, the push sends assigneeId, and use-remote rewrites the display name from the fetched node. Priority, state, and labels follow the same template in the next commit. It's not wired into save-issue yet — that's the next commit. I tested it in isolation: the engine's three-way and conflict branches over a synthetic spec, plus the assignee saver end to end with a stubbed fetch and update.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-pearl-save.el109
1 files changed, 109 insertions, 0 deletions
diff --git a/tests/test-pearl-save.el b/tests/test-pearl-save.el
index 36358ad..edd2c2d 100644
--- a/tests/test-pearl-save.el
+++ b/tests/test-pearl-save.el
@@ -288,6 +288,115 @@ the conflict choice (stubbed). Returns (:outcome :fetched :pushed :applied
(should (equal '("remote changed") (plist-get r :applied)))
(should (string= (secure-hash 'sha256 "remote changed") (plist-get r :hash)))))
+;;; --run-atomic-field-save (the per-field engine for enum/relation fields)
+
+(cl-defun test-pearl-save--atomic (&key live baseline remote (push-success t) resolution)
+ "Drive `pearl--run-atomic-field-save' over a synthetic spec; return a results plist."
+ (let (outcome pushed committed-local committed-remote)
+ (cl-letf (((symbol-function 'pearl--read-atomic-conflict-resolution)
+ (lambda (_) resolution)))
+ (pearl--run-atomic-field-save
+ (list :issue-id "u" :identifier "ENG-1" :field 'assignee :marker nil
+ :label "ENG-1 assignee" :live live :baseline baseline :equal #'string=
+ :live-display (format "L:%s" live)
+ :remote-display (lambda (r) (format "R:%s" r))
+ :fetch-remote (lambda (cb) (funcall cb remote))
+ :push (lambda (cb) (setq pushed t) (funcall cb (list :success push-success)))
+ :commit-local (lambda () (setq committed-local t))
+ :commit-remote (lambda (r) (setq committed-remote r)))
+ (lambda (o) (setq outcome o))))
+ (list :status (plist-get outcome :status) :reason (plist-get outcome :reason)
+ :pushed pushed :local committed-local :remote committed-remote)))
+
+(ert-deftest test-pearl-atomic-missing-baseline-skips ()
+ "A nil baseline (legacy file) is skipped with missing-property, no fetch."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline nil :remote "x")))
+ (should (eq 'skipped (plist-get r :status)))
+ (should (eq 'missing-property (plist-get r :reason)))
+ (should-not (plist-get r :pushed))))
+
+(ert-deftest test-pearl-atomic-live-equals-baseline-unchanged ()
+ "Live equal to baseline is unchanged (defensive; the scan shouldn't queue it)."
+ (let ((r (test-pearl-save--atomic :live "a" :baseline "a" :remote "x")))
+ (should (eq 'unchanged (plist-get r :status)))
+ (should-not (plist-get r :pushed))))
+
+(ert-deftest test-pearl-atomic-remote-converged-advances-no-push ()
+ "Remote already equal to live converges: advance baseline, unchanged, no push."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "b")))
+ (should (eq 'unchanged (plist-get r :status)))
+ (should (plist-get r :local))
+ (should-not (plist-get r :pushed))))
+
+(ert-deftest test-pearl-atomic-clean-push ()
+ "Remote unmoved from baseline: clean push, baseline advances to live."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "a")))
+ (should (eq 'pushed (plist-get r :status)))
+ (should (plist-get r :pushed))
+ (should (plist-get r :local))))
+
+(ert-deftest test-pearl-atomic-clean-push-failure ()
+ "A failed clean push reports failed/push-failed and does not advance baseline."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "a"
+ :push-success nil)))
+ (should (eq 'failed (plist-get r :status)))
+ (should (eq 'push-failed (plist-get r :reason)))
+ (should-not (plist-get r :local))))
+
+(ert-deftest test-pearl-atomic-conflict-use-local ()
+ "Remote moved differently: use-local pushes and advances."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "c"
+ :resolution 'use-local)))
+ (should (eq 'pushed (plist-get r :status)))
+ (should (plist-get r :pushed))
+ (should (plist-get r :local))))
+
+(ert-deftest test-pearl-atomic-conflict-use-remote ()
+ "Use-remote adopts the remote value, never pushes, reports resolved-remote."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "c"
+ :resolution 'use-remote)))
+ (should (eq 'resolved-remote (plist-get r :status)))
+ (should-not (plist-get r :pushed))
+ (should (string= "c" (plist-get r :remote)))))
+
+(ert-deftest test-pearl-atomic-conflict-cancel ()
+ "Cancel leaves the field in conflict, pushes and commits nothing."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "c"
+ :resolution 'cancel)))
+ (should (eq 'conflict (plist-get r :status)))
+ (should (eq 'cancelled (plist-get r :reason)))
+ (should-not (plist-get r :pushed))
+ (should-not (plist-get r :local))
+ (should-not (plist-get r :remote))))
+
+(ert-deftest test-pearl-atomic-fetch-failure ()
+ "A remote fetch error reports failed/fetch-failed."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote :error)))
+ (should (eq 'failed (plist-get r :status)))
+ (should (eq 'fetch-failed (plist-get r :reason)))))
+
+;;; --save-assignee-field (the relation saver, end to end)
+
+(ert-deftest test-pearl-save-assignee-clean-push-advances-baseline ()
+ "A picker-changed assignee with an unmoved remote pushes and advances the baseline."
+ (test-pearl-save--in-rendered
+ '(:id "u" :identifier "ENG-1" :title "t" :priority 2
+ :state (:id "s1" :name "Todo") :assignee (:id "a1" :name "Craig")
+ :description "body")
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-ASSIGNEE-ID" "a2") ; the picker chose a new assignee
+ (let ((marker (point-marker)) outcome (pushed nil))
+ (cl-letf (((symbol-function 'pearl--fetch-issue-async)
+ (lambda (_id cb) (funcall cb '((assignee (id . "a1") (name . "Craig"))))))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (_id input cb)
+ (setq pushed (cdr (assoc "assigneeId" input)))
+ (funcall cb '(:success t)))))
+ (pearl--save-assignee-field marker (lambda (o) (setq outcome o)))
+ (should (eq 'pushed (plist-get outcome :status)))
+ (should (string= "a2" pushed))
+ (should (string= "a2" (org-entry-get marker "LINEAR-ASSIGNEE-ID-SYNCED")))))))
+
;;; --save-description-field (advances both provenance hashes)
(ert-deftest test-pearl-save-description-advances-both-hashes ()