aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-28 09:28:53 -0500
committerCraig Jennings <c@cjennings.net>2026-05-28 09:28:53 -0500
commitfd94237903c15883173e438a5af4ba4e6bfdbd46 (patch)
tree05bd91e8c66f0332c8acb174aa64257e262367ac /tests
parentc8b9ad1b72c60f87c15e942f17f7ff1066a41cb2 (diff)
downloadpearl-fd94237903c15883173e438a5af4ba4e6bfdbd46.tar.gz
pearl-fd94237903c15883173e438a5af4ba4e6bfdbd46.zip
feat(view-sync): pearl-pick-source distinguishes [saved] / [saved → scope] / [view]
This is Phase 4 of docs/saved-query-sync-spec.org (§ Extended pearl-pick-source candidate label). The picker now reads where each saved query lives at a glance: - `[saved] Name`: local-only saved query, dispatches as :type filter (unchanged) - `[saved → Engineering] Name`: synced to a team-scoped Linear view - `[saved → Personal] Name`: synced as a personal Linear view - `[saved → ?] Name`: synced, but the team id no longer resolves (deleted/renamed on Linear) or the teams cache wasn't loaded - `[view] Name`: Linear-authored view favorite (unchanged) A synced entry's source plist gets :type view, so the existing view-dispatch branch in pearl-pick-source routes through pearl--query-view-async against the linked Linear view. Refresh and pagination honor whatever the view's filter is on Linear now, not the local authoring filter pearl built it from. I added pearl--saved-query-scope-label to resolve the scope string from (spec, teams) and extended pearl--pick-source-candidates with an optional teams argument. pearl-pick-source fetches pearl--all-teams lazily, only when at least one saved query carries :linear-view-id, and feeds the result in. An all-local configuration pays no extra network cost. While in the picker code I also closed a latent gap the review surfaced: pearl-open-current-view-in-linear was about to break on synced-saved-query sources because the source plist had no :url. I extended pearl--customview-create-async and -update-async to fetch `url` in the response, pearl--save-query-mark-synced to take an optional VIEW-URL arg and store it as :linear-view-url, and the picker source plist to surface :url when the entry has one. Entries synced before this commit have no stored URL. A re-sync repopulates it. Mark-synced preserves a prior URL when VIEW-URL is omitted, so a re-sync whose API response somehow lacks the URL doesn't erase the working one. 15 new tests across test-pearl-favorites.el (the picker labels for team-scope, Personal, unknown-team, no-teams-arg, URL surfaced + URL absent, mixed local/synced) and test-pearl-saved-query-sync.el (mark-synced stores URL when given, preserves prior URL on re-sync omission). The existing 4 pick-source tests stay green. They pass either no teams arg or nil, so the scope-resolution branch is dead in their cases. 653 ert tests total green. make compile and make lint clean. Lower-severity findings from the review filed as follow-ups: the `?` label conflates "unknown team" and "no teams data" (could be split). pearl--all-teams cache has no TTL so stale team names persist across renames. The lazy teams fetch reuses the "Fetching favorites..." progress message during its own roundtrip. Synced entries silently drop the local :sort/:order because Linear's view doesn't carry them in v1.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-pearl-favorites.el123
-rw-r--r--tests/test-pearl-saved-query-sync.el28
2 files changed, 151 insertions, 0 deletions
diff --git a/tests/test-pearl-favorites.el b/tests/test-pearl-favorites.el
index b47cde6..70507d7 100644
--- a/tests/test-pearl-favorites.el
+++ b/tests/test-pearl-favorites.el
@@ -213,6 +213,129 @@ For an issue node, IDENTIFIER fills the issue's user-facing key."
"No favorites and no saved queries yields no candidates."
(should (null (pearl--pick-source-candidates nil nil))))
+;;; pick-source candidate builder -- synced saved queries (view-sync Phase 4)
+
+(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-team-scope ()
+ "A team-scoped synced entry renders as `[saved \\u2192 <TeamName>] <Name>'."
+ (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))))
+ (saved '(("My team view" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id "team-eng-id"
+ :linear-view-shared t)))
+ (cands (pearl--pick-source-candidates nil saved teams)))
+ (should (assoc "[saved → Engineering] My team view" cands))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-personal-scope ()
+ "A personal synced entry renders as `[saved \\u2192 Personal] <Name>'."
+ (let* ((saved '(("Mine" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id nil
+ :linear-view-shared :json-false)))
+ (cands (pearl--pick-source-candidates nil saved nil)))
+ (should (assoc "[saved → Personal] Mine" cands))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-unknown-team-renders-q ()
+ "A synced entry whose team-id isn't in TEAMS renders `[saved \\u2192 ?] <Name>'."
+ (let* ((teams '(((id . "other-id") (key . "OTH") (name . "Other"))))
+ (saved '(("Stale" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id "gone-team-id")))
+ (cands (pearl--pick-source-candidates nil saved teams)))
+ (should (assoc "[saved → ?] Stale" cands))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-no-teams-arg-renders-q ()
+ "When TEAMS is nil and an entry has a non-nil team-id, scope is `?'."
+ (let* ((saved '(("S" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id "team-x"))))
+ (should (assoc "[saved → ?] S"
+ (pearl--pick-source-candidates nil saved nil)))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-dispatches-as-view ()
+ "A synced entry's source plist has :type view with :id = :linear-view-id."
+ (let* ((saved '(("S" :filter (:open t)
+ :linear-view-id "view-uuid-X"
+ :linear-view-team-id nil)))
+ (cands (pearl--pick-source-candidates nil saved nil))
+ (source (cdr (assoc "[saved → Personal] S" cands))))
+ (should (eq 'view (plist-get source :type)))
+ (should (equal "view-uuid-X" (plist-get source :id)))
+ (should (equal "S" (plist-get source :name)))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-source-includes-url-when-stored ()
+ "A synced entry carrying `:linear-view-url' surfaces it in the source plist
+so `pearl-open-current-view-in-linear' can dispatch to the browser."
+ (let* ((saved '(("S" :filter (:open t)
+ :linear-view-id "view-uuid-X"
+ :linear-view-team-id nil
+ :linear-view-url "https://linear.app/ws/view/abc")))
+ (cands (pearl--pick-source-candidates nil saved nil))
+ (source (cdr (assoc "[saved → Personal] S" cands))))
+ (should (equal "https://linear.app/ws/view/abc"
+ (plist-get source :url)))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-source-no-url-when-not-stored ()
+ "An entry synced before `:linear-view-url' tracking landed has no URL in the
+source plist; the open-in-Linear command errors cleanly until the user re-syncs."
+ (let* ((saved '(("S" :filter (:open t)
+ :linear-view-id "view-uuid-X"
+ :linear-view-team-id nil)))
+ (cands (pearl--pick-source-candidates nil saved nil))
+ (source (cdr (assoc "[saved → Personal] S" cands))))
+ (should-not (plist-get source :url))))
+
+(ert-deftest test-pearl-pick-source-candidates-local-only-saved-query-keeps-saved-label ()
+ "A saved query without :linear-view-id still renders as `[saved] <Name>' and dispatches as filter."
+ (let* ((saved '(("Local" :filter (:open t))))
+ (cands (pearl--pick-source-candidates nil saved '()))
+ (source (cdr (assoc "[saved] Local" cands))))
+ (should source)
+ (should (eq 'filter (plist-get source :type)))
+ (should (equal '(:open t) (plist-get source :filter)))))
+
+(ert-deftest test-pearl-pick-source-candidates-mixed-local-and-synced ()
+ "A picker mixing local-only and synced entries renders both label styles."
+ (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))))
+ (saved '(("Local thing" :filter (:open t))
+ ("Synced thing" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id "team-eng-id")))
+ (cands (pearl--pick-source-candidates nil saved teams))
+ (labels (mapcar #'car cands)))
+ (should (member "[saved] Local thing" labels))
+ (should (member "[saved → Engineering] Synced thing" labels))))
+
+;;; pearl--saved-query-scope-label
+
+(ert-deftest test-pearl-saved-query-scope-label-local-only-returns-nil ()
+ "An entry without :linear-view-id has no scope and returns nil."
+ (should (null (pearl--saved-query-scope-label '(:filter (:open t)) nil))))
+
+(ert-deftest test-pearl-saved-query-scope-label-personal-when-no-team-id ()
+ "A synced entry with nil team-id renders as \"Personal\"."
+ (should (equal "Personal"
+ (pearl--saved-query-scope-label
+ '(:filter (:open t) :linear-view-id "v" :linear-view-team-id nil)
+ nil))))
+
+(ert-deftest test-pearl-saved-query-scope-label-resolves-team-name-from-teams ()
+ "A synced entry with a team-id resolves to that team's name."
+ (let ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering")))))
+ (should (equal "Engineering"
+ (pearl--saved-query-scope-label
+ '(:filter (:open t)
+ :linear-view-id "v" :linear-view-team-id "team-eng-id")
+ teams)))))
+
+(ert-deftest test-pearl-saved-query-scope-label-unknown-team-id-renders-q ()
+ "An entry whose team-id doesn't appear in TEAMS renders \"?\"."
+ (let ((teams '(((id . "other-id") (name . "Other")))))
+ (should (equal "?"
+ (pearl--saved-query-scope-label
+ '(:filter (:open t)
+ :linear-view-id "v" :linear-view-team-id "gone")
+ teams)))))
+
;;; open-favorite-url
(ert-deftest test-pearl-open-favorite-url-with-url-browses ()
diff --git a/tests/test-pearl-saved-query-sync.el b/tests/test-pearl-saved-query-sync.el
index ebf4622..830f738 100644
--- a/tests/test-pearl-saved-query-sync.el
+++ b/tests/test-pearl-saved-query-sync.el
@@ -239,6 +239,34 @@
(pearl--save-query-mark-synced "Q" nil nil "view-uuid")
:type 'user-error)))
+(ert-deftest test-pearl-save-query-mark-synced-stores-view-url-when-given ()
+ "The optional VIEW-URL arg lands in the entry as :linear-view-url."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t))))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil)))
+ (pearl--save-query-mark-synced
+ "Q" nil nil "view-uuid" "https://linear.app/ws/view/abc"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (equal "https://linear.app/ws/view/abc"
+ (plist-get spec :linear-view-url))))))
+
+(ert-deftest test-pearl-save-query-mark-synced-keeps-prior-url-when-omitted ()
+ "Omitting VIEW-URL on a re-sync preserves any previously stored URL.
+Re-sync paths whose API response doesn't carry a URL shouldn't erase a
+URL the create path already wrote."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t)
+ :linear-view-id "old-id"
+ :linear-view-team-id nil
+ :linear-view-shared :json-false
+ :linear-view-url "https://linear.app/ws/view/old")))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil)))
+ ;; No VIEW-URL passed -- the prior URL should survive.
+ (pearl--save-query-mark-synced "Q" nil nil "new-id"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (equal "https://linear.app/ws/view/old"
+ (plist-get spec :linear-view-url))))))
+
(ert-deftest test-pearl-save-query-mark-synced-overwrites-prior-sync-metadata ()
"Re-syncing an already-synced entry overwrites the four `:linear-view-*' keys."
(let ((pearl-saved-queries