diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-25 20:20:00 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-25 20:20:00 -0500 |
| commit | 09cf1f02161aaef9bb1ad26907472bea52f53a24 (patch) | |
| tree | 9ad920b04eaa01d0e8bf0744fc4384a865956118 /tests | |
| parent | 8fa0febbc3a3afe78d1a5abbbb944adf6dd3612a (diff) | |
| download | pearl-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.el | 109 |
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 () |
